From c26c0122ce65f5c0c840bbdad1a0a0dde7d073b7 Mon Sep 17 00:00:00 2001 From: Abhinav Sinha <37282098+zeborg@users.noreply.github.com> Date: Tue, 26 May 2026 20:18:08 +0530 Subject: [PATCH] Initial commit --- Dockerfile | 83 + LICENSE | 202 + README.md | 137 + backend/package.json | 14 + backend/server.js | 537 +++ frontend/index.html | 15 + frontend/package.json | 23 + frontend/public/logo.svg | 59 + frontend/src/App.jsx | 346 ++ frontend/src/App.module.css | 139 + frontend/src/components/BundleNav.jsx | 146 + frontend/src/components/BundleNav.module.css | 241 ++ frontend/src/components/ExamReport.jsx | 104 + frontend/src/components/ExamReport.module.css | 127 + frontend/src/components/ExamStartModal.jsx | 99 + .../src/components/ExamStartModal.module.css | 186 + frontend/src/components/ExamTimer.jsx | 91 + frontend/src/components/ExamTimer.module.css | 146 + frontend/src/components/Header.jsx | 60 + frontend/src/components/Header.module.css | 145 + frontend/src/components/ScenarioPanel.jsx | 376 ++ .../src/components/ScenarioPanel.module.css | 516 +++ frontend/src/components/Sidebar.jsx | 210 + frontend/src/components/Sidebar.module.css | 339 ++ frontend/src/components/Terminal.jsx | 173 + frontend/src/components/Terminal.module.css | 91 + frontend/src/index.css | 143 + frontend/src/main.jsx | 14 + frontend/vite.config.js | 14 + scenarios/SCHEMA.md | 211 + scenarios/bundles.json | 146 + scenarios/scenarios.json | 3498 +++++++++++++++++ scripts/entrypoint.sh | 186 + scripts/nginx.conf | 49 + 34 files changed, 8866 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/package.json create mode 100644 backend/server.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/logo.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/App.module.css create mode 100644 frontend/src/components/BundleNav.jsx create mode 100644 frontend/src/components/BundleNav.module.css create mode 100644 frontend/src/components/ExamReport.jsx create mode 100644 frontend/src/components/ExamReport.module.css create mode 100644 frontend/src/components/ExamStartModal.jsx create mode 100644 frontend/src/components/ExamStartModal.module.css create mode 100644 frontend/src/components/ExamTimer.jsx create mode 100644 frontend/src/components/ExamTimer.module.css create mode 100644 frontend/src/components/Header.jsx create mode 100644 frontend/src/components/Header.module.css create mode 100644 frontend/src/components/ScenarioPanel.jsx create mode 100644 frontend/src/components/ScenarioPanel.module.css create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/Sidebar.module.css create mode 100644 frontend/src/components/Terminal.jsx create mode 100644 frontend/src/components/Terminal.module.css create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js create mode 100644 scenarios/SCHEMA.md create mode 100644 scenarios/bundles.json create mode 100644 scenarios/scenarios.json create mode 100644 scripts/entrypoint.sh create mode 100644 scripts/nginx.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d42135 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,83 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV KUBECONFIG=/etc/rancher/k3s/k3s.yaml +ENV PROGRESS_FILE=/data/progress.json +# k3s writes its kubeconfig here; make kubectl pick it up automatically +ENV K3S_KUBECONFIG_MODE=644 + +# ── System deps ───────────────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y \ + curl wget git vim nano jq bash bash-completion \ + ca-certificates gnupg lsb-release \ + nginx \ + iproute2 iptables iputils-ping \ + procps htop \ + mount kmod \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +# ── Node.js 20 ──────────────────────────────────────────────────────────────── +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# ── k3s (bundles kubectl, containerd, everything) ──────────────────────────── +# We download the binary and the airgap images so the cluster starts offline. +RUN set -eux && \ + case "$(uname -m)" in \ + x86_64) K3S_BIN="k3s" ;; \ + aarch64) K3S_BIN="k3s-arm64" ;; \ + *) echo "Unsupported arch: $(uname -m)" && exit 1 ;; \ + esac && \ + curl -fsSL "https://github.com/k3s-io/k3s/releases/latest/download/${K3S_BIN}" \ + -o /usr/local/bin/k3s && \ + chmod +x /usr/local/bin/k3s && \ + ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl && \ + ln -sf /usr/local/bin/k3s /usr/local/bin/crictl + + +# ── App files ───────────────────────────────────────────────────────────────── +WORKDIR /app + +# Install backend dependencies +COPY backend/package.json ./backend/ +RUN cd backend && npm install --production + +# Build frontend +COPY frontend/package.json frontend/vite.config.js ./frontend/ +RUN cd frontend && npm install + +COPY frontend/ ./frontend/ +RUN cd frontend && npm run build + +# Copy everything else +COPY backend/ ./backend/ +COPY scenarios/ ./scenarios/ +COPY scripts/entrypoint.sh /entrypoint.sh +COPY scripts/nginx.conf /etc/nginx/nginx.conf + +# Strip any Windows-style \r from the entrypoint so heredocs inside it +# don't produce scripts with \r in shebang lines (causes execvp ENOENT). +RUN sed -i 's/\r//' /entrypoint.sh && chmod +x /entrypoint.sh + + +# ── Directories & k3s static config ───────────────────────────────────────── +RUN mkdir -p /root/.kube /data /var/log /tmp/k8s-state \ + && mkdir -p /var/log/nginx \ + && mkdir -p /etc/rancher/k3s \ + && mkdir -p /var/lib/rancher/k3s + +# Tell k3s to use the native snapshotter. +# We rely on k3s to generate its full default containerd config.toml +# (which includes CNI paths, runtimes, etc.) and only override the snapshotter. +# Do NOT place a custom config.toml.tmpl here — a minimal template breaks +# node registration by omitting the CNI and internal-opt sections. +RUN printf 'snapshotter: "native"\nwrite-kubeconfig-mode: "644"\n' \ + > /etc/rancher/k3s/config.yaml + +# ── Expose ──────────────────────────────────────────────────────────────────── +# Single port - nginx proxies everything +EXPOSE 80 + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dac983b --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 The KubeKosh Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d8ffbd --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +
+ KubeKosh Logo + +

KubeKosh

+ +

Self-hosted Kubernetes Lab for Hands-on Learning

+ +

+ Docker Hub + License + Platforms +

+
+ +--- + +KubeKosh runs a real [K3s](https://k3s.io/) Kubernetes cluster inside a single Docker container and pairs it with a browser-based terminal and automated scenario validation — no cloud account or local cluster required. + +## Quick Start + +**Prerequisite:** [Docker](https://docs.docker.com/get-docker/) + +```bash +docker run -itd --name kubekosh --privileged -p 7554:80 zeborg/kubekosh:latest +``` + +Open **http://localhost:7554** — wait ~30 seconds for the *Cluster Ready* indicator to turn green. + +> `--privileged` is required — K3s needs access to kernel namespaces and cgroups. + +### Persist Progress + +```bash +docker run -itd --name kubekosh --privileged -p 7554:80 \ + -v :/data zeborg/kubekosh:latest +``` + +Progress is stored in SQLite at `/data/progress.db` inside the container. You may mount your own custom directory to `/data` to persist the progress across container restarts. + +### Build From Source + +```bash +docker build -t kubekosh . +# multi-platform +docker buildx build --platform linux/amd64,linux/arm64 -t kubekosh . +``` + +--- + +## What's Inside + +| Bundle | Focus | Exam Mode | +|---|---|---| +| 🌱 Kubernetes Basics | Core concepts | 60 min | +| 🧑‍✈️ Kubernetes Administrator | CKA | 120 min | +| 🛠️ Kubernetes Developer | CKAD | 120 min | +| 🛡️ Kubernetes Security | CKS | 120 min | + +**Scenario types:** +- **Task** — Hands-on challenge in the live terminal. Click **Validate** for automated cluster-state checking. +- **MCQ** — Multiple-choice question with a detailed explanation on submission. + +### Shell Aliases + +The terminal comes pre-configured with: + +| Alias | Expands to | +|---|---| +| `k` | `kubectl` | +| `kgp` | `kubectl get pods` | +| `kga` | `kubectl get pods --all-namespaces` | +| `kgd` | `kubectl get deployments` | +| `kgs` | `kubectl get services` | +| `kaf` | `kubectl apply -f` | +| `kex` | `kubectl exec -it` | +| `kns ` | `kubectl config set-context --current --namespace=` | + +--- + +## Architecture + +| Component | Technology | +|---|---| +| Frontend | React + Vite, `xterm.js` | +| Backend | Node.js / Express, `node-pty` WebSocket PTY | +| Cluster | K3s (single-node, in-container) | +| Proxy | nginx on container port `80`, mapped to host port `7554` | +| Storage | SQLite (`better-sqlite3`) at `/data/progress.db` | + +Everything runs inside a **single Docker image** managed by `scripts/entrypoint.sh`. + +--- + +## Contributing + +Contributions are what make open-source projects like this one grow — and every contribution counts, big or small. Whether you're fixing a typo, polishing a scenario description, or building a completely new exercise from scratch, you're helping the next person learn Kubernetes in the best way possible. **Thank you for taking the time!** + +### Adding Scenarios + +Scenarios live in `scenarios/scenarios.json`; bundles in `scenarios/bundles.json`. See [`scenarios/SCHEMA.md`](scenarios/SCHEMA.md) for the full schema. + +**Task checklist:** +- `validation.commands` — idempotent `kubectl` commands only +- `setup_commands` / `teardown_commands` — `kubectl` or native Ubuntu commands only + +**MCQ checklist:** +- `correct_option` must match one of the `options[].id` values +- Always include an `explanation` + +### Workflow + +```bash +# 1. Fork the repo on GitHub, then clone your fork +git clone https://github.com//kubekosh.git +cd kubekosh + +# 2. Create a branch +git checkout -b feat/my-scenario + +# 3. Edit scenarios/scenarios.json (and/or bundles.json) + +# 4. Build and test locally +docker build -t kubekosh . && docker run --rm -itd --privileged -p 7554:80 kubekosh + +# 5. Commit and push to your fork +git add scenarios/scenarios.json +git commit -m "feat: add scenario" +git push -u origin feat/my-scenario +``` + +Open a Pull Request from your fork's branch against `main`. + +--- + +## License + +Apache 2.0 License — see [LICENSE](LICENSE). diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..689b230 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "kubekosh-api", + "version": "0.1.0", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "better-sqlite3": "^9.4.3", + "node-pty": "^1.0.0", + "ws": "^8.17.0" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..3493eff --- /dev/null +++ b/backend/server.js @@ -0,0 +1,537 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const { execSync, exec } = require('child_process'); +const Database = require('better-sqlite3'); + +const app = express(); +const PORT = 4000; + +app.use(express.json()); +app.use(express.static(path.join(__dirname, '../frontend/dist'))); + +const SCENARIOS_FILE = path.join(__dirname, '../scenarios/scenarios.json'); +const BUNDLES_FILE = path.join(__dirname, '../scenarios/bundles.json'); +const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db'; + +// ── SQLite progress store ───────────────────────────────────────────────────── + +let _db = null; +function getDb() { + if (_db) return _db; + const dir = path.dirname(DB_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + _db = new Database(DB_FILE); + _db.exec(` + CREATE TABLE IF NOT EXISTS progress ( + scenario_id TEXT PRIMARY KEY, + status TEXT, + attempts INTEGER DEFAULT 0, + last_validated TEXT, + completed_at TEXT + ) + `); + _db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL, + started_at TEXT NOT NULL, + submitted_at TEXT, + status TEXT NOT NULL DEFAULT 'active', + exam_minutes INTEGER NOT NULL DEFAULT 120, + duration_secs INTEGER, + snapshot TEXT + ) + `); + return _db; +} + +function loadProgress() { + try { + const rows = getDb().prepare('SELECT * FROM progress').all(); + return Object.fromEntries(rows.map(r => [r.scenario_id, { + status: r.status, + attempts: r.attempts, + last_validated: r.last_validated, + completed_at: r.completed_at, + }])); + } catch { return {}; } +} + +function saveProgress(progress) { + try { + const db = getDb(); + const upsert = db.prepare(` + INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(scenario_id) DO UPDATE SET + status = excluded.status, + attempts = excluded.attempts, + last_validated = excluded.last_validated, + completed_at = excluded.completed_at + `); + const tx = db.transaction((entries) => { + for (const [id, p] of entries) { + upsert.run(id, p.status, p.attempts || 0, p.last_validated || null, p.completed_at || null); + } + }); + tx(Object.entries(progress)); + } catch (e) { + console.error('Failed to save progress:', e.message); + } +} + + +function loadScenarios() { + return JSON.parse(fs.readFileSync(SCENARIOS_FILE, 'utf8')); +} + +function loadBundles() { + return JSON.parse(fs.readFileSync(BUNDLES_FILE, 'utf8')); +} + +function runCommand(cmd, timeoutMs = 15000) { + try { + const output = execSync(cmd, { + timeout: timeoutMs, + encoding: 'utf8', + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG || '/root/.kube/config' } + }).trim(); + return { success: true, output }; + } catch (e) { + return { success: false, output: (e.stdout || '').trim(), error: (e.stderr || e.message || '').trim() }; + } +} + +function checkMatch(actual, expected, matchType) { + const a = actual.trim(); + const e = expected.trim(); + if (matchType === 'exact') return a === e; + if (matchType === 'contains') return a.includes(e); + if (matchType === 'not_contains') return !a.includes(e); + if (matchType === 'regex') return new RegExp(e).test(a); + return a === e; +} + +// Active WebSocket terminal clients — write output directly (NOT as shell input) +const activeWsClients = new Set() +// Active PTY shells — used only to write '\r' and trigger PS1 prompt repaint +const activeShells = new Set() + +// Inject text directly into all terminals as output (never touches shell stdin) +function injectToTerminal(text) { + for (const ws of activeWsClients) { + try { + if (ws.readyState === WebSocket.OPEN) ws.send(text) + } catch (_) {} + } +} + +// Write a carriage-return to every active shell so bash repaints its PS1 prompt +function refreshPrompt(delayMs = 80) { + setTimeout(() => { + for (const shell of activeShells) { + try { shell.write('\r') } catch (_) {} + } + }, delayMs) +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +// POST /api/progress/reset — reset progress for scenario | category | bundle +app.post('/api/progress/reset', (req, res) => { + const { scope, scenarioId, category, bundleId } = req.body; + const db = getDb(); + const del = db.prepare('DELETE FROM progress WHERE scenario_id = ?'); + + try { + if (scope === 'scenario') { + del.run(scenarioId); + } else if (scope === 'category') { + const scenarios = loadScenarios(); + const ids = scenarios.filter(s => s.category === category).map(s => s.id); + const tx = db.transaction(ids => ids.forEach(id => del.run(id))); + tx(ids); + } else if (scope === 'bundle') { + const bundle = loadBundles().find(b => b.id === bundleId); + if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); + const tx = db.transaction(ids => ids.forEach(id => del.run(id))); + tx(bundle.scenario_ids); + } else { + return res.status(400).json({ error: 'Invalid scope' }); + } + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// ── Exam sessions ───────────────────────────────────────────────────────────── + +// POST /api/sessions — start a new exam session +app.post('/api/sessions', (req, res) => { + const { bundleId, examMinutes } = req.body; + const bundle = loadBundles().find(b => b.id === bundleId); + if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); + const db = getDb(); + const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120)); + // Abandon any existing active session + db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') + WHERE status='active'`).run(); + const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes) + VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins); + res.json({ id, bundleId, status: 'active', exam_minutes: mins }); +}); + +// GET /api/sessions/active — get the current active session +app.get('/api/sessions/active', (req, res) => { + const db = getDb(); + const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get(); + if (!session) return res.json(null); + const bundle = loadBundles().find(b => b.id === session.bundle_id); + const progress = loadProgress(); + const scenarioIds = bundle?.scenario_ids || []; + const completed = scenarioIds.filter(id => progress[id]?.status === 'completed').length; + res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed }); +}); + +// POST /api/sessions/:id/submit — submit the exam session +app.post('/api/sessions/:id/submit', (req, res) => { + const db = getDb(); + const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id); + if (!session) return res.status(404).json({ error: 'Session not found' }); + const bundle = loadBundles().find(b => b.id === session.bundle_id); + const progress = loadProgress(); + const scenarios = loadScenarios(); + const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id)); + const snapshot = bundleScenarios.map(s => ({ + id: s.id, title: s.title, weight: s.weight, + category: s.category, type: s.type, difficulty: s.difficulty, + status: progress[s.id]?.status || 'not_started', + completed_at: progress[s.id]?.completed_at || null, + attempts: progress[s.id]?.attempts || 0, + })); + const startedAt = new Date(session.started_at + 'Z'); + const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000); + db.prepare(`UPDATE sessions SET status='submitted', submitted_at=datetime('now'), + duration_secs=?, snapshot=? WHERE id=?`) + .run(durationSecs, JSON.stringify(snapshot), req.params.id); + res.json({ ok: true, snapshot, durationSecs }); +}); + +// POST /api/sessions/:id/abandon — forfeit the exam without a score report +app.post('/api/sessions/:id/abandon', (req, res) => { + const db = getDb(); + const result = db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') + WHERE id=? AND status='active'`).run(req.params.id); + res.json({ ok: true, changed: result.changes }); +}); + + +// POST /api/scenarios/:id/teardown — run teardown_commands +app.post('/api/scenarios/:id/teardown', (req, res) => { + const scenarios = loadScenarios(); + const scenario = scenarios.find(s => s.id === req.params.id); + if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); + const results = []; + for (const item of (scenario.teardown_commands || [])) { + const cmd = item.command; + const result = runCommand(cmd, 30000); + results.push({ command: cmd, ...result }); + } + res.json({ ok: true, results }); +}); + +// ── Context sync (Feature 3) ────────────────────────────────────────────────── + +// POST /api/scenarios/:id/context — inject namespace + banner into active terminals +app.post('/api/scenarios/:id/context', (req, res) => { + const scenarios = loadScenarios(); + const scenario = scenarios.find(s => s.id === req.params.id); + if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); + const ns = scenario.default_namespace || 'default'; + + // VT sequences: clear visible screen + scrollback, then move cursor to top-left + const clearScreen = '\x1b[2J\x1b[3J\x1b[H'; + const line = '\u2500'.repeat(54); + const banner = [ + `\x1b[2m# ${line}\x1b[0m\r\n`, + `\x1b[1m\x1b[36m\u2388 Scenario : \x1b[0m\x1b[1m\x1b[97m${scenario.title}\x1b[0m\r\n`, + `\x1b[2m Namespace: \x1b[0m\x1b[33m${ns}\x1b[0m`, + ` \x1b[2mDifficulty: \x1b[0m${scenario.difficulty === 'Easy' ? '\x1b[32m' : scenario.difficulty === 'Hard' ? '\x1b[31m' : '\x1b[33m'}${scenario.difficulty}\x1b[0m\r\n`, + `\x1b[2m# ${line}\x1b[0m\r\n`, + ].join(''); + + // Set kubectl context namespace silently + runCommand(`kubectl config set-context --current --namespace=${ns}`, 5000); + + // 1) Clear screen and write banner as terminal output (never touches shell stdin) + injectToTerminal(clearScreen + banner); + + // 2) After banner renders, write \r to each PTY so bash repaints its PS1 prompt + refreshPrompt(80); + + res.json({ ok: true, namespace: ns }); +}); + +// GET /api/bundles — list bundles with per-bundle progress stats +app.get('/api/bundles', (req, res) => { + const bundles = loadBundles(); + const scenarios = loadScenarios(); + const progress = loadProgress(); + const result = bundles.map(b => { + const total = b.scenario_ids.length; + const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').length; + return { ...b, stats: { total, completed } }; + }); + res.json(result); +}); + +// GET /api/scenarios — list scenarios; optional ?bundle= filter +app.get('/api/scenarios', (req, res) => { + const scenarios = loadScenarios(); + const progress = loadProgress(); + const { bundle } = req.query; + + let filtered = scenarios; + if (bundle) { + const bundles = loadBundles(); + const b = bundles.find(x => x.id === bundle); + if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id)); + } + + const list = filtered.map(s => ({ + id: s.id, + title: s.title, + category: s.category, + difficulty: s.difficulty, + type: s.type, + weight: s.weight, + progress: progress[s.id] || { status: 'not_started', attempts: 0 } + })); + res.json(list); +}); + +// GET /api/scenarios/:id — full scenario detail +app.get('/api/scenarios/:id', (req, res) => { + const scenarios = loadScenarios(); + const scenario = scenarios.find(s => s.id === req.params.id); + if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); + const progress = loadProgress(); + res.json({ + ...scenario, + progress: progress[scenario.id] || { status: 'not_started', attempts: 0 } + }); +}); + +// POST /api/scenarios/:id/setup — run setup_commands for a scenario +app.post('/api/scenarios/:id/setup', (req, res) => { + const scenarios = loadScenarios(); + const scenario = scenarios.find(s => s.id === req.params.id); + if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); + + const results = []; + for (const item of (scenario.setup_commands || [])) { + const cmd = item.command; + const result = runCommand(cmd, 30000); + results.push({ command: cmd, ...result }); + if (!result.success) { + // Non-fatal: setup commands like "kubectl create namespace" fail if already exists + console.warn(`Setup command warning: ${cmd} -> ${result.error}`); + } + } + + // Mark scenario as in-progress + const progress = loadProgress(); + if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') { + progress[scenario.id] = { + ...progress[scenario.id], + status: 'in_progress', + attempts: (progress[scenario.id]?.attempts || 0), + started_at: progress[scenario.id]?.started_at || new Date().toISOString() + }; + saveProgress(progress); + } + + res.json({ setup_results: results }); +}); + +// POST /api/scenarios/:id/validate — validate task-based scenario +app.post('/api/scenarios/:id/validate', (req, res) => { + const scenarios = loadScenarios(); + const scenario = scenarios.find(s => s.id === req.params.id); + if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); + if (scenario.type !== 'task') return res.status(400).json({ error: 'Not a task scenario' }); + + const checks = []; + let allPassed = true; + + for (const check of (scenario.validation?.commands || [])) { + const result = runCommand(check.command, 5000); + // Prefer stdout. Only fall back to stderr for non-API errors: + // - kubectl auth can-i prints "yes"/"no" to stdout → already in result.output. + // - kubectl get prints "Error from server (NotFound):" to stderr + // and nothing to stdout → suppress, return '' so the check fails cleanly. + const isKubectlApiError = result.error && + /^Error from server|^error:|^Error:/i.test(result.error.trim()); + const actual = result.output || (isKubectlApiError ? '' : result.error) || ''; + const passed = checkMatch(actual, check.expected_output, check.match); + + checks.push({ + description: check.description, + command: check.command, + expected: check.expected_output, + actual, + passed + }); + if (!passed) allPassed = false; + } + + // Update progress + const progress = loadProgress(); + const prev = progress[scenario.id] || { attempts: 0 }; + progress[scenario.id] = { + ...prev, + status: allPassed ? 'completed' : 'in_progress', + attempts: (prev.attempts || 0) + 1, + last_validated: new Date().toISOString(), + completed_at: allPassed ? new Date().toISOString() : prev.completed_at + }; + saveProgress(progress); + + res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts }); +}); + +// POST /api/scenarios/:id/answer — submit MCQ answer +app.post('/api/scenarios/:id/answer', (req, res) => { + const { selected } = req.body; + const scenarios = loadScenarios(); + const scenario = scenarios.find(s => s.id === req.params.id); + if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); + if (scenario.type !== 'mcq') return res.status(400).json({ error: 'Not an MCQ scenario' }); + + const correct = selected === scenario.correct_option; + + const progress = loadProgress(); + const prev = progress[scenario.id] || { attempts: 0 }; + progress[scenario.id] = { + ...prev, + status: correct ? 'completed' : 'in_progress', + attempts: (prev.attempts || 0) + 1, + last_answer: selected, + last_validated: new Date().toISOString(), + completed_at: correct ? new Date().toISOString() : prev.completed_at + }; + saveProgress(progress); + + res.json({ + correct, + correct_option: scenario.correct_option, + explanation: scenario.explanation, + attempts: progress[scenario.id].attempts + }); +}); + +// GET /api/progress — full progress summary +app.get('/api/progress', (req, res) => { + const scenarios = loadScenarios(); + const progress = loadProgress(); + const total = scenarios.length; + const completed = Object.values(progress).filter(p => p.status === 'completed').length; + const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0); + const earnedWeight = scenarios + .filter(s => progress[s.id]?.status === 'completed') + .reduce((sum, s) => sum + (s.weight || 0), 0); + res.json({ + total, completed, + score_pct: totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0, + details: progress + }); +}); + +// POST /api/progress/reset/:id — reset a scenario +app.post('/api/progress/reset/:id', (req, res) => { + const progress = loadProgress(); + delete progress[req.params.id]; + saveProgress(progress); + res.json({ ok: true }); +}); + +// GET /api/health +app.get('/api/health', (req, res) => { + const kube = runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1'); + res.json({ api: 'ok', cluster: kube.success ? 'ready' : 'not_ready', cluster_info: kube.output }); +}); + +// Fallback to frontend SPA +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); +}); + +// ── WebSocket PTY terminal ──────────────────────────────────────────────────── +const http = require('http'); +const WebSocket = require('ws'); +const pty = require('node-pty'); + +const server = http.createServer(app); +const wss = new WebSocket.Server({ noServer: true }); + +wss.on('connection', (ws) => { + activeWsClients.add(ws); + + const shell = pty.spawn('/bin/bash', [], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: '/root', + env: { + ...process.env, + KUBECONFIG: '/root/.kube/config', + HOME: '/root', + TERM: 'xterm-256color', + }, + }); + + activeShells.add(shell); + + // Forward PTY output → browser + shell.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(data); + }); + + shell.onExit(() => { + activeWsClients.delete(ws); + activeShells.delete(shell); + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); + + // Forward browser input → PTY + ws.on('message', (msg) => { + try { + const parsed = JSON.parse(msg); + if (parsed.type === 'resize') { + shell.resize(Number(parsed.cols) || 80, Number(parsed.rows) || 24); + return; + } + } catch (_) { /* not JSON → raw input */ } + shell.write(typeof msg === 'string' ? msg : msg.toString()); + }); + + ws.on('close', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} }); + ws.on('error', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} }); +}); + +// Handle WebSocket upgrades only for /shell-ws +server.on('upgrade', (req, socket, head) => { + if (req.url === '/shell-ws') { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); + } else { + socket.destroy(); + } +}); + +server.listen(PORT, () => console.log(`API server running on :${PORT}`)); + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6e93cfc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + KubeKosh — Interactive Kubernetes Playground + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c2f1153 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "kubekosh-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.8" + } +} \ No newline at end of file diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..5fce556 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,59 @@ + + KubeKosh icon + KubeKosh heptagon logo mark in blue with a white innermost layer and dark terminal prompt icon. + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..850d3fa --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,346 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import Sidebar from './components/Sidebar' +import ScenarioPanel from './components/ScenarioPanel' +import Terminal from './components/Terminal' +import Header from './components/Header' +import BundleNav from './components/BundleNav' +import ExamTimer from './components/ExamTimer' +import ExamReport from './components/ExamReport' +import ExamStartModal from './components/ExamStartModal' +import styles from './App.module.css' + +const MIN_SIDEBAR_W = 180 +const MAX_SIDEBAR_W = 560 +const DEFAULT_SIDEBAR_W = 280 +const SIDEBAR_COLLAPSE_PX = 100 +const SIDEBAR_COLLAPSED_W = 40 + +const MIN_TERM_H = 36 +const MAX_TERM_H = 600 +const DEFAULT_TERM_H = 280 +const TERM_COLLAPSE_PX = 60 + +export default function App() { + const [bundles, setBundles] = useState([]) + const [activeBundleId, setActiveBundleId] = useState(null) + + const [scenarios, setScenarios] = useState([]) + const [activeId, setActiveId] = useState(null) + const [scenario, setScenario] = useState(null) + const [progress, setProgress] = useState({}) + const [clusterReady, setClusterReady] = useState(false) + const [loading, setLoading] = useState(true) + + // ── Exam mode ───────────────────────────────────────────────────────────── + const [examSession, setExamSession] = useState(null) // active session object + const [examReport, setExamReport] = useState(null) // submitted report + const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal + + // Sidebar resize / collapse + const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const sbDragging = useRef(false) + const sbDragX0 = useRef(0) + const sbDragW0 = useRef(0) + + // Terminal resize / collapse + const [termH, setTermH] = useState(300) + const [termCollapsed, setTermCollapsed] = useState(false) + const [bundlesCollapsed, setBundlesCollapsed] = useState(false) + const tmDragging = useRef(false) + const tmDragY0 = useRef(0) + const tmDragH0 = useRef(0) + + // Track previous scenario id to teardown on switch + const prevActiveIdRef = useRef(null) + + // ── Cluster health ──────────────────────────────────────────────────────── + useEffect(() => { + async function check() { + try { + const d = await fetch('/api/health').then(r => r.json()) + setClusterReady(d.cluster === 'ready') + } catch { setClusterReady(false) } + } + check() + const t = setInterval(check, 8000) + return () => clearInterval(t) + }, []) + + // ── Load bundles (once) ─────────────────────────────────────────────────── + useEffect(() => { + fetch('/api/bundles') + .then(r => r.json()) + .then(data => { + setBundles(data) + if (data.length > 0) setActiveBundleId(data[0].id) + }) + .catch(console.error) + }, []) + + // ── Restore active exam session on load ─────────────────────────────────── + useEffect(() => { + fetch('/api/sessions/active') + .then(r => r.json()) + .then(s => { if (s) setExamSession(s) }) + .catch(() => { }) + }, []) + + // ── Load scenarios for active bundle ───────────────────────────────────── + useEffect(() => { + if (!activeBundleId) return + setLoading(true) + setActiveId(null) + setScenario(null) + const url = `/api/scenarios?bundle=${activeBundleId}` + fetch(url) + .then(r => r.json()) + .then(data => { + setScenarios(data) + setProgress(Object.fromEntries(data.map(s => [s.id, s.progress]))) + }) + .catch(console.error) + .finally(() => setLoading(false)) + }, [activeBundleId]) + + // ── Load full scenario when selected — teardown previous, load new ───────── + useEffect(() => { + if (!activeId) return + + // Teardown the previously active scenario's cluster state on switch + const prevId = prevActiveIdRef.current + if (prevId && prevId !== activeId) { + fetch(`/api/scenarios/${prevId}/teardown`, { method: 'POST' }).catch(() => { }) + } + prevActiveIdRef.current = activeId + + setScenario(null) + fetch(`/api/scenarios/${activeId}`) + .then(r => r.json()) + .then(s => { + setScenario(s) + // Feature 3: sync terminal context when scenario selected + fetch(`/api/scenarios/${activeId}/context`, { method: 'POST' }).catch(() => { }) + }) + .catch(console.error) + }, [activeId]) + + const refreshProgress = useCallback(async () => { + const [bundleData, scenarioData] = await Promise.all([ + fetch('/api/bundles').then(r => r.json()), + fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()), + ]) + setBundles(bundleData) + setScenarios(scenarioData) + setProgress(Object.fromEntries(scenarioData.map(s => [s.id, s.progress]))) + if (activeId) { + const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json()) + setScenario(d2) + } + // Refresh exam session completion count + if (examSession) { + const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null) + if (updated) setExamSession(updated) + } + }, [activeBundleId, activeId, examSession]) + + // ── Exam actions ────────────────────────────────────────────────────────── + const startExam = useCallback(async (bundleId, customMinutes) => { + const res = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bundleId, examMinutes: customMinutes }), + }).then(r => r.json()) + // refresh to get scenarioCount + const active = await fetch('/api/sessions/active').then(r => r.json()) + setExamSession(active) + setActiveBundleId(bundleId) + setActiveId(null) + setScenario(null) + }, []) + + const submitExam = useCallback(async () => { + if (!examSession) return + const result = await fetch(`/api/sessions/${examSession.id}/submit`, { method: 'POST' }) + .then(r => r.json()) + const bundle = bundles.find(b => b.id === examSession.bundle_id) + setExamReport({ ...result, bundle }) + setExamSession(null) + }, [examSession, bundles]) + + const abandonExam = useCallback(async () => { + if (!examSession) return + await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { }) + setExamSession(null) + }, [examSession]) + + // ── Teardown when restarting a scenario (Feature 2) ─────────────────────── + const handleScenarioStart = useCallback(async (scenarioId) => { + // Run teardown first to clean cluster state + await fetch(`/api/scenarios/${scenarioId}/teardown`, { method: 'POST' }).catch(() => { }) + // Then setup will be called by ScenarioPanel as before + }, []) + + // ── Sidebar drag ───────────────────────────────────────────────────────── + const onSidebarDragDown = useCallback((e) => { + e.preventDefault() + sbDragging.current = true + sbDragX0.current = e.clientX + sbDragW0.current = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW + + function onMove(ev) { + if (!sbDragging.current) return + const newW = sbDragW0.current + (ev.clientX - sbDragX0.current) + if (newW < SIDEBAR_COLLAPSE_PX) { + setSidebarCollapsed(true) + } else { + setSidebarCollapsed(false) + setSidebarW(Math.min(MAX_SIDEBAR_W, Math.max(MIN_SIDEBAR_W, newW))) + } + } + function onUp() { + sbDragging.current = false + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [sidebarCollapsed, sidebarW]) + + // ── Terminal drag ──────────────────────────────────────────────────────── + const onTermDragDown = useCallback((e) => { + e.preventDefault() + tmDragging.current = true + tmDragY0.current = e.clientY + tmDragH0.current = termCollapsed ? MIN_TERM_H : termH + + function onMove(ev) { + if (!tmDragging.current) return + const newH = tmDragH0.current + (tmDragY0.current - ev.clientY) + if (newH < TERM_COLLAPSE_PX) { + setTermCollapsed(true) + } else { + setTermCollapsed(false) + setTermH(Math.min(MAX_TERM_H, Math.max(MIN_TERM_H + 40, newH))) + } + } + function onUp() { + tmDragging.current = false + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [termCollapsed, termH]) + + const currentSidebarW = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW + const currentTermH = termCollapsed ? MIN_TERM_H : termH + const activeBundle = bundles.find(b => b.id === activeBundleId) || null + const isMcq = scenario?.type === 'mcq' + + return ( +
+
+ + {/* Bundle navigation bar */} + { + // In exam mode, only allow switching within the exam bundle + if (examSession && id !== examSession.bundle_id) return + setActiveBundleId(id); setActiveId(null); setScenario(null) + }} + onProgressUpdate={refreshProgress} + onStartExam={setExamModalBundle} + collapsed={bundlesCollapsed} + onToggleCollapse={() => setBundlesCollapsed(c => !c)} + /> + + {/* Exam timer bar */} + {examSession && ( + + )} + +
+ {/* Sidebar */} + setSidebarCollapsed(c => !c)} + width={currentSidebarW} + activeBundleId={activeBundleId} + onProgressUpdate={refreshProgress} + /> + + {/* Sidebar resize handle */} +
+ + {/* Main area */} +
+
+ +
+ + {!isMcq && ( +
+ )} + + {!isMcq && ( +
+ setTermCollapsed(c => !c)} + /> +
+ )} +
+
+ + {/* Exam report modal */} + {examReport && ( + setExamReport(null)} + onRetry={() => { + setExamReport(null) + setExamModalBundle(examReport.bundle) + }} + /> + )} + + {/* Exam start modal */} + {examModalBundle && ( + { + setExamModalBundle(null) + startExam(examModalBundle.id, mins) + }} + onCancel={() => setExamModalBundle(null)} + /> + )} +
+
© {new Date().getFullYear()} The KubeKosh Project • All rights reserved
+
+ Made with ❤️ by zeborg +
+
+
+ ) +} diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css new file mode 100644 index 0000000..9eb5dca --- /dev/null +++ b/frontend/src/App.module.css @@ -0,0 +1,139 @@ +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.body { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; + min-width: 0; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +/* Scenario panel */ +.scenarioWrap { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +/* ── Sidebar resize handle ────────────────────────────────────────────────── */ +.sidebarHandle { + flex-shrink: 0; + width: 5px; + cursor: ew-resize; + background: var(--border); + transition: background 0.15s; + position: relative; + z-index: 10; + user-select: none; +} +.sidebarHandle:hover, +.sidebarHandle:active { + background: var(--green); +} +/* Grip indicator for sidebar */ +.sidebarHandle::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 3px; + height: 32px; + border-radius: 2px; + background: var(--border2); + pointer-events: none; +} +.sidebarHandle:hover::before, +.sidebarHandle:active::before { + background: var(--surface); +} + +/* ── Terminal resize handle ───────────────────────────────────────────────── */ +.termHandle { + flex-shrink: 0; + height: 5px; + cursor: ns-resize; + background: var(--border); + transition: background 0.15s; + position: relative; + z-index: 10; + user-select: none; +} +.termHandle:hover, +.termHandle:active { + background: var(--green); +} +/* Grip indicator */ +.termHandle::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 3px; + border-radius: 2px; + background: var(--border2); + pointer-events: none; +} +.termHandle:hover::before, +.termHandle:active::before { + background: var(--surface); +} + +/* ── Terminal wrapper ─────────────────────────────────────────────────────── */ +.terminalWrap { + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; + transition: height 0.15s ease; +} + +/* ── Footer ───────────────────────────────────────────────────────────────── */ +.footer { + flex-shrink: 0; + text-align: center; + padding: 6px 12px; + font-size: 10px; + color: var(--text-3); + background: var(--surface); + border-top: 1px solid var(--border); + font-family: var(--sans); + letter-spacing: 0.3px; + line-height: 1.5; +} + +.footerLink { + color: var(--text-2); + text-decoration: none; + font-weight: 600; + transition: color 0.15s; +} +.footerLink:hover { + color: var(--green); + text-decoration: underline; +} + +.heart { + display: inline-block; + font-size: 9px; + margin: 0 1px; + transform: translateY(-0.5px); +} diff --git a/frontend/src/components/BundleNav.jsx b/frontend/src/components/BundleNav.jsx new file mode 100644 index 0000000..2454c21 --- /dev/null +++ b/frontend/src/components/BundleNav.jsx @@ -0,0 +1,146 @@ +import { useState, useRef } from 'react' +import styles from './BundleNav.module.css' + +async function resetProgress(scope, opts) { + await fetch('/api/progress/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope, ...opts }), + }) +} + +export default function BundleNav({ + bundles, activeBundleId, examSession, onSelect, onProgressUpdate, onStartExam, collapsed, onToggleCollapse +}) { + const trackRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [startX, setStartX] = useState(0) + const [scrollLeft, setScrollLeft] = useState(0) + const [dragDist, setDragDist] = useState(0) + + const handleMouseDown = (e) => { + if (!trackRef.current) return + setIsDragging(true) + setDragDist(0) + setStartX(e.pageX - trackRef.current.offsetLeft) + setScrollLeft(trackRef.current.scrollLeft) + } + + const handleMouseLeaveOrUp = () => { + setIsDragging(false) + } + + const handleMouseMove = (e) => { + if (!isDragging || !trackRef.current) return + e.preventDefault() + const x = e.pageX - trackRef.current.offsetLeft + const walk = x - startX + if (Math.abs(walk) > 5) setDragDist(Math.abs(walk)) + trackRef.current.scrollLeft = scrollLeft - walk + } + + return ( + <> + + + ) +} diff --git a/frontend/src/components/BundleNav.module.css b/frontend/src/components/BundleNav.module.css new file mode 100644 index 0000000..fa7546f --- /dev/null +++ b/frontend/src/components/BundleNav.module.css @@ -0,0 +1,241 @@ +.nav { + flex-shrink: 0; + background: var(--surface); + border-bottom: 1px solid var(--border); + overflow: hidden; + display: flex; + align-items: center; + justify-content: flex-end; + position: relative; +} + +.fadeOverlay { + position: absolute; + top: 0; + right: 42px; /* 8px margin + 26px width + 8px gap */ + height: 100%; + width: 40px; + background: linear-gradient(to right, transparent, var(--surface)); + pointer-events: none; + z-index: 5; +} + +/* Horizontal scroll container — no visible scrollbar */ +.track { + display: flex; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + gap: 4px; + padding: 8px 10px; +} +.track::-webkit-scrollbar { display: none; } + +.track { + cursor: grab; +} +.track.dragging { + cursor: grabbing; +} +.track.dragging * { + pointer-events: none; /* Prevent text selection and hover states while dragging */ +} + +.collapseWrap { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin: 0 8px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: var(--text-3); + font-size: 11px; + transition: background 0.15s, color 0.15s; + user-select: none; +} +.collapseWrap:hover { + background: var(--surface2); + color: var(--text); +} + +/* ── Tab button ────────────────────────────────────────────────────────────── */ +.tab { + position: relative; + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 8px; + padding: 9px 16px 10px; + min-width: 210px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + color: var(--text-2); + font-family: var(--sans); + text-align: left; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.tab:hover { + background: var(--surface2); + border-color: var(--border2); +} + +.tab.active { + background: var(--bdim, rgba(63,185,80,0.10)); + border-color: color-mix(in srgb, var(--bcolor) 45%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--bcolor) 20%, transparent) inset; +} + +/* ── Top row ───────────────────────────────────────────────────────────────── */ +.tabTop { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.icon { + font-size: 18px; + line-height: 1; + flex-shrink: 0; + margin-top: 1px; +} + +.text { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.name { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.1px; + color: var(--text); +} +.tab.active .name { color: var(--bcolor, var(--green)); } + +.tagline { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Count badge: stacked number + percentage */ +.countWrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; + flex-shrink: 0; +} + +.countNum { + font-family: var(--mono); + font-size: 12px; + font-weight: 700; + color: var(--bcolor, var(--text-2)); + opacity: 0.9; +} + +.countPct { + font-family: var(--mono); + font-size: 10px; + color: var(--text-3); +} +.tab.active .countPct { color: var(--bcolor, var(--text-3)); opacity: 0.7; } + +/* Bundle reset button */ +.bundleResetBtn { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + font-size: 14px; + color: var(--text-3); + padding: 2px 5px; + border-radius: 4px; + line-height: 1; + margin-left: 2px; + transition: color 0.12s, background 0.12s; +} +.bundleResetBtn:hover { + color: var(--red); + background: var(--red-dim); +} + +/* ── Inline progress track ─────────────────────────────────────────────────── */ +.progressTrack { + width: 100%; + height: 3px; + background: var(--surface3); + border-radius: 2px; + overflow: hidden; +} + +.tab.active .progressTrack { + background: color-mix(in srgb, var(--bcolor) 25%, var(--border2)); +} + +.progressFill { + height: 100%; + background: var(--bcolor, var(--green)); + border-radius: 2px; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Glow on active tab's fill */ +.tab.active .progressFill { + box-shadow: 0 0 6px color-mix(in srgb, var(--bcolor) 60%, transparent); +} + +/* Locked tab during exam */ +.tab.locked { + opacity: 0.4; + cursor: not-allowed; + filter: grayscale(0.5); +} + +/* Start Exam button */ +.examBtn { + flex-shrink: 0; + background: var(--blue-dim); + border: 1px solid color-mix(in srgb, var(--blue) 35%, transparent); + color: var(--blue); + border-radius: 5px; + padding: 3px 8px; + font-size: 11px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, transform 0.1s; + white-space: nowrap; +} +.tab:hover .examBtn { opacity: 1; } +.examBtn:hover { background: var(--blue); color: #fff; transform: scale(1.05); } + +/* In-exam badge */ +.examBadge { + flex-shrink: 0; + background: rgba(252,196,25,0.15); + border: 1px solid rgba(252,196,25,0.35); + color: var(--amber); + border-radius: 5px; + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} diff --git a/frontend/src/components/ExamReport.jsx b/frontend/src/components/ExamReport.jsx new file mode 100644 index 0000000..88980c2 --- /dev/null +++ b/frontend/src/components/ExamReport.jsx @@ -0,0 +1,104 @@ +import styles from './ExamReport.module.css' + +function formatDuration(secs) { + if (!secs) return '—' + const h = Math.floor(secs / 3600) + const m = Math.floor((secs % 3600) / 60) + const s = secs % 60 + if (h > 0) return `${h}h ${m}m ${s}s` + if (m > 0) return `${m}m ${s}s` + return `${s}s` +} + +const DIFF_COLOR = { Easy: 'var(--green)', Medium: 'var(--amber)', Hard: 'var(--red)' } + +export default function ExamReport({ report, bundle, onClose, onRetry }) { + if (!report) return null + + const { snapshot, durationSecs } = report + const completed = snapshot.filter(s => s.status === 'completed') + const totalWeight = snapshot.reduce((a, s) => a + (s.weight || 0), 0) + const earnedWeight = completed.reduce((a, s) => a + (s.weight || 0), 0) + const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0 + + // Group by category + const byCategory = snapshot.reduce((acc, s) => { + ;(acc[s.category] = acc[s.category] || []).push(s) + return acc + }, {}) + + const passed = pct >= 66 + + return ( +
e.target === e.currentTarget && onClose()}> +
+ {/* Header */} +
+
+ {bundle?.icon || '🎓'} +
+
Exam Report
+
{bundle?.name}
+
+
+ +
+ + {/* Score hero */} +
+
+ + + + + {pct}% +
+
+
+ {passed ? '✅ Passed' : '❌ Not Yet Passing'} +
+
+ {completed.length}/{snapshot.length} scenarios + · + {earnedWeight}/{totalWeight} points + · + ⏱ {formatDuration(durationSecs)} +
+
Pass mark: 66%
+
+
+ + {/* Breakdown by category */} +
+ {Object.entries(byCategory).map(([cat, items]) => ( +
+
{cat}
+ {items.map(s => ( +
+ {s.status === 'completed' ? '✅' : '⬜'} + {s.title} + + {s.difficulty} + + {s.status === 'completed' ? s.weight : 0}/{s.weight} pts +
+ ))} +
+ ))} +
+ + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/ExamReport.module.css b/frontend/src/components/ExamReport.module.css new file mode 100644 index 0000000..6826b9b --- /dev/null +++ b/frontend/src/components/ExamReport.module.css @@ -0,0 +1,127 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +.modal { + background: var(--surface); + border: 1px solid var(--border2); + border-radius: var(--radius-lg); + width: min(680px, 95vw); + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 60px rgba(0,0,0,0.4); +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px 14px; + border-bottom: 1px solid var(--border); +} +.headerLeft { display: flex; align-items: center; gap: 12px; } +.bundleIcon { font-size: 28px; } +.examLabel { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--text-3); } +.bundleName { font-size: 16px; font-weight: 700; color: var(--text); } +.closeBtn { + background: none; border: none; cursor: pointer; + color: var(--text-3); font-size: 18px; padding: 4px 8px; + border-radius: 6px; transition: color 0.15s, background 0.15s; +} +.closeBtn:hover { color: var(--text); background: var(--surface3); } + +/* Score hero */ +.hero { + display: flex; + align-items: center; + gap: 24px; + padding: 24px 28px; + border-bottom: 1px solid var(--border); +} +.passed { background: var(--green-dim); } +.failed { background: var(--red-dim); } + +.scoreRing { position: relative; width: 80px; height: 80px; flex-shrink: 0; } +.ring { width: 80px; height: 80px; transform: rotate(-90deg); } +.ringTrack { fill: none; stroke: var(--surface3); stroke-width: 6; } +.ringFill { + fill: none; stroke-width: 6; stroke-linecap: round; + transition: stroke-dashoffset 1s ease; +} +.pctText { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--text); +} + +.heroMeta { flex: 1; } +.verdict { font-size: 20px; font-weight: 800; margin-bottom: 6px; } +.verdictPass { color: var(--green); } +.verdictFail { color: var(--red); } +.heroStats { display: flex; gap: 8px; font-size: 13px; color: var(--text-2); margin-bottom: 4px; } +.passMark { font-size: 11px; color: var(--text-3); } + +/* Breakdown */ +.breakdown { + flex: 1; + overflow-y: auto; + padding: 16px 22px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.catGroup { display: flex; flex-direction: column; gap: 4px; } +.catTitle { + font-size: 10px; font-weight: 800; letter-spacing: 0.8px; + text-transform: uppercase; color: var(--text-3); + padding-bottom: 6px; border-bottom: 1px solid var(--border); + margin-bottom: 2px; +} + +.row { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border-radius: 6px; + font-size: 13px; color: var(--text-2); + transition: background 0.1s; +} +.row:hover { background: var(--surface2); } +.rowDone { color: var(--text); } +.rowIcon { font-size: 14px; flex-shrink: 0; } +.rowTitle { flex: 1; } +.rowDiff { font-size: 11px; font-weight: 600; flex-shrink: 0; } +.rowPts { font-family: var(--mono); font-size: 12px; color: var(--text-3); flex-shrink: 0; min-width: 60px; text-align: right; } + +/* Actions */ +.actions { + display: flex; gap: 10px; justify-content: flex-end; + padding: 14px 22px; + border-top: 1px solid var(--border); +} +.retryBtn { + padding: 8px 18px; + background: var(--blue); color: #fff; + border: none; border-radius: 8px; + font-size: 13px; font-weight: 700; font-family: var(--sans); + cursor: pointer; transition: opacity 0.15s; +} +.retryBtn:hover { opacity: 0.85; } +.closeBtn2 { + padding: 8px 18px; + background: var(--surface3); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; + font-size: 13px; font-weight: 600; font-family: var(--sans); + cursor: pointer; transition: background 0.15s; +} +.closeBtn2:hover { background: var(--border); } diff --git a/frontend/src/components/ExamStartModal.jsx b/frontend/src/components/ExamStartModal.jsx new file mode 100644 index 0000000..d61ae14 --- /dev/null +++ b/frontend/src/components/ExamStartModal.jsx @@ -0,0 +1,99 @@ +import { useState, useEffect, useRef } from 'react' +import styles from './ExamStartModal.module.css' + +export default function ExamStartModal({ bundle, onStart, onCancel }) { + const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120) + const inputRef = useRef(null) + + useEffect(() => { + // Focus input on open + const t = setTimeout(() => inputRef.current?.select(), 60) + return () => clearTimeout(t) + }, []) + + if (!bundle) return null + + const numMinutes = Number(minutes) + const isValid = numMinutes >= 5 && numMinutes <= 300 + + const handleStart = () => { + if (!isValid) return + onStart(numMinutes) + } + + const presets = [ + { label: '30 min', value: 30 }, + { label: '60 min', value: 60 }, + { label: '90 min', value: 90 }, + { label: '120 min', value: 120 }, + ] + + return ( +
e.target === e.currentTarget && onCancel()}> +
+
+ {bundle.icon} +
+
Start Exam
+
{bundle.name}
+
+
+ +
+
+ 📋 + {bundle.scenario_ids?.length || '?'} scenarios · Recommended: {bundle.exam_minutes} min +
+ +
+ +
+ {presets.map(p => ( + + ))} +
+
+ setMinutes(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleStart()} + /> + minutes +
+
+ {!isValid ? ( + ⚠ Duration must be between 5 and 300 minutes + ) : numMinutes < 60 ? '⚡ Speed run mode' : + numMinutes <= 120 ? '🎯 Realistic exam timing' : + '🧘 Relaxed practice pace'} +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/ExamStartModal.module.css b/frontend/src/components/ExamStartModal.module.css new file mode 100644 index 0000000..127ecb3 --- /dev/null +++ b/frontend/src/components/ExamStartModal.module.css @@ -0,0 +1,186 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 900; + animation: fadeIn 0.15s ease; +} + +.modal { + background: var(--surface); + border: 1px solid var(--border2); + border-radius: var(--radius-lg); + width: min(420px, 94vw); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 20px 50px rgba(0,0,0,0.4); + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.header { + display: flex; + align-items: center; + gap: 14px; + padding: 18px 22px 14px; + border-bottom: 1px solid var(--border); +} + +.icon { font-size: 30px; } + +.title { + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-3); +} + +.bundleName { + font-size: 17px; + font-weight: 800; + color: var(--text); +} + +.body { + padding: 18px 22px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.info { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-2); + background: var(--surface2); + padding: 8px 12px; + border-radius: 8px; +} + +.field { + display: flex; + flex-direction: column; + gap: 10px; +} + +.label { + font-size: 12px; + font-weight: 700; + color: var(--text-2); + letter-spacing: 0.3px; +} + +.presets { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.preset { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface2); + color: var(--text-2); + font-size: 12px; + font-weight: 600; + font-family: var(--sans); + cursor: pointer; + transition: all 0.15s; +} +.preset:hover { + border-color: var(--bcolor, var(--border2)); + color: var(--text); +} +.preset.presetActive { + background: color-mix(in srgb, var(--bcolor, var(--blue)) 15%, transparent); + border-color: var(--bcolor, var(--blue)); + color: var(--bcolor, var(--blue)); +} + +.customRow { + display: flex; + align-items: center; + gap: 8px; +} + +.input { + width: 90px; + padding: 8px 12px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 16px; + font-family: var(--mono); + font-weight: 700; + text-align: center; + transition: border-color 0.15s; + outline: none; +} +.input:focus { border-color: var(--blue); } + +.unit { + font-size: 13px; + color: var(--text-3); +} + +.hint { + font-size: 12px; + color: var(--text-3); + font-style: italic; +} + +.actions { + display: flex; + gap: 10px; + justify-content: flex-end; + padding: 14px 22px; + border-top: 1px solid var(--border); +} + +.cancelBtn { + padding: 8px 18px; + background: var(--surface3); + color: var(--text-2); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + font-weight: 600; + font-family: var(--sans); + cursor: pointer; + transition: background 0.15s; +} +.cancelBtn:hover { background: var(--border); color: var(--text); } + +.startBtn { + padding: 8px 22px; + border: none; + border-radius: 8px; + color: #fff; + font-size: 13px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + transition: opacity 0.15s, transform 0.1s; +} +.startBtn:hover:not(:disabled) { opacity: 0.88; transform: scale(1.03); } +.startBtn:active:not(:disabled) { transform: scale(0.97); } +.startBtn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + filter: grayscale(1); +} diff --git a/frontend/src/components/ExamTimer.jsx b/frontend/src/components/ExamTimer.jsx new file mode 100644 index 0000000..b683d9c --- /dev/null +++ b/frontend/src/components/ExamTimer.jsx @@ -0,0 +1,91 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import styles from './ExamTimer.module.css' + +function formatTime(secs) { + if (secs < 0) secs = 0 + const h = Math.floor(secs / 3600) + const m = Math.floor((secs % 3600) / 60) + const s = secs % 60 + if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` + return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` +} + +export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) { + const [elapsed, setElapsed] = useState(0) + const autoSubmitted = useRef(false) + // session.exam_minutes is set at start time with the user's custom value + const durationSecs = (session?.exam_minutes || bundle?.exam_minutes || 120) * 60 + + useEffect(() => { + if (!session) return + autoSubmitted.current = false + const startedAt = new Date(session.started_at + (session.started_at.endsWith('Z') ? '' : 'Z')) + + const tick = () => { + const el = Math.floor((Date.now() - startedAt.getTime()) / 1000) + if (el >= durationSecs && !autoSubmitted.current) { + autoSubmitted.current = true + setElapsed(durationSecs) + onSubmit() + } else if (!autoSubmitted.current) { + setElapsed(el) + } + } + + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [session, durationSecs, onSubmit]) + + const remaining = durationSecs - elapsed + const pct = Math.min(100, (elapsed / durationSecs) * 100) + const urgent = remaining < 600 // < 10 min + + const handleSubmit = useCallback(async () => { + if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return + onSubmit() + }, [session, onSubmit]) + + const handleAbandon = useCallback(() => { + if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return + onAbandon() + }, [onAbandon]) + + if (!session) return null + + return ( +
+
+ +
+ EXAM MODE + {bundle?.name} +
+
+ +
+
+ {formatTime(elapsed)} + / + {formatTime(durationSecs)} + {urgent && ⚠ Running out of time} +
+
+
+
+
+ {session.completedCount || 0} / {session.scenarioCount || '?'} completed +
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/ExamTimer.module.css b/frontend/src/components/ExamTimer.module.css new file mode 100644 index 0000000..e439a02 --- /dev/null +++ b/frontend/src/components/ExamTimer.module.css @@ -0,0 +1,146 @@ +.timer { + display: flex; + align-items: center; + gap: 20px; + padding: 10px 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); + animation: slideIn 0.3s ease; + transition: background 0.25s; +} + +.timer.urgent { + background: rgba(255, 107, 107, 0.08); + border-bottom-color: rgba(255, 107, 107, 0.35); +} +.timer.urgent .elapsed { color: var(--red); } +.timer.urgent .fill { background: var(--red); } + +.left { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.icon { font-size: 20px; } + +.meta { + display: flex; + flex-direction: column; + gap: 1px; +} + +.label { + font-size: 9px; + font-weight: 800; + letter-spacing: 1px; + color: var(--blue); + text-transform: uppercase; +} + +.bundleName { + font-size: 12px; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.center { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.timeDisplay { + display: flex; + align-items: baseline; + gap: 4px; +} + +.elapsed { + font-family: var(--mono); + font-size: 18px; + font-weight: 700; + color: var(--text); + line-height: 1; +} + +.sep { + font-size: 14px; + color: var(--text-3); +} + +.total { + font-family: var(--mono); + font-size: 13px; + color: var(--text-3); +} + +.bar { + height: 4px; + background: var(--surface3); + border-radius: 2px; + overflow: hidden; +} + +.fill { + height: 100%; + background: var(--blue); + border-radius: 2px; + transition: width 1s linear; +} + +.progress { + font-size: 11px; + color: var(--text-3); +} + +.right { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.abandonBtn { + padding: 7px 14px; + background: none; + color: var(--red); + border: 1px solid color-mix(in srgb, var(--red) 40%, transparent); + border-radius: 8px; + font-size: 12px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +.abandonBtn:hover { background: var(--red-dim); transform: scale(1.03); } +.abandonBtn:active { transform: scale(0.97); } + +.submitBtn { + padding: 7px 18px; + background: var(--blue); + color: #fff; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + transition: opacity 0.15s, transform 0.1s; + white-space: nowrap; +} +.submitBtn:hover { opacity: 0.85; transform: scale(1.03); } +.submitBtn:active { transform: scale(0.97); } + +.urgentTag { + font-size: 11px; + font-weight: 700; + color: var(--red); + animation: pulse 1.5s infinite; + margin-left: 6px; +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..5cf8630 --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -0,0 +1,60 @@ +import { useState, useEffect } from 'react' +import styles from './Header.module.css' + +export default function Header({ clusterReady }) { + const [theme, setTheme] = useState( + () => localStorage.getItem('kubekosh-theme') || 'dark' + ) + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme) + localStorage.setItem('kubekosh-theme', theme) + }, [theme]) + + const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark') + + return ( +
+
+
+ KubeKosh Logo + KubeKosh + v0.1.0 +
+ Interactive Kubernetes Playground +
+ +
+ {/* GitHub link */} + + + + + + + {/* Theme toggle */} + + + {/* Cluster status */} +
+ + {clusterReady ? 'Cluster Ready' : 'Connecting…'} +
+
+
+ ) +} diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css new file mode 100644 index 0000000..0729c9a --- /dev/null +++ b/frontend/src/components/Header.module.css @@ -0,0 +1,145 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; + height: 52px; + padding: 0 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + gap: 24px; + z-index: 100; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.logo { + display: flex; + align-items: center; + gap: 8px; +} + +.logoImage { + width: 22px; + height: 22px; + display: block; +} + +.logoText { + font-family: var(--sans); + font-weight: 900; + font-size: 19px; + letter-spacing: -0.5px; + color: var(--text); +} + +.version { + font-size: 11px; + font-family: var(--mono); + color: var(--text-2); + background: var(--surface2); + border: 1px solid var(--border); + padding: 1px 5px; + border-radius: 4px; + margin-left: 2px; +} + +.tagline { + font-size: 12px; + color: var(--text-3); + font-family: var(--mono); + border-left: 1px solid var(--border2); + padding-left: 12px; +} + +.right { + flex-shrink: 0; + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; +} + +.githubBtn { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.2s, color 0.15s; + color: var(--text-2); + line-height: 1; + text-decoration: none; +} +.githubBtn:hover { + background: var(--surface2); + border-color: var(--border2); + transform: rotate(12deg); + color: var(--text); +} + +.themeBtn { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.2s; + line-height: 1; +} +.themeBtn:hover { + background: var(--surface2); + border-color: var(--border2); + transform: rotate(12deg); +} + +.clusterBadge { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 12px; + border-radius: 20px; + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + border: 1px solid; +} + +.clusterBadge.ready { + background: var(--green-dim); + border-color: rgba(57,217,138,0.3); + color: var(--green); +} + +.clusterBadge.notReady { + background: var(--amber-dim); + border-color: rgba(252,196,25,0.3); + color: var(--amber); +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.ready .dot { + animation: pulse 2s infinite; +} + +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} } diff --git a/frontend/src/components/ScenarioPanel.jsx b/frontend/src/components/ScenarioPanel.jsx new file mode 100644 index 0000000..c181ab9 --- /dev/null +++ b/frontend/src/components/ScenarioPanel.jsx @@ -0,0 +1,376 @@ +import { useState, useEffect } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import styles from './ScenarioPanel.module.css' + +// Inline markdown: renders without a wrapping

— safe for buttons/spans +const inlineComponents = { + p: ({ children }) => <>{children}, + code: ({ children }) => {children}, +} +function InlineMd({ children }) { + return ( + + {children} + + ) +} + +async function resetProgress(scope, opts) { + await fetch('/api/progress/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope, ...opts }), + }) +} + +export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode }) { + const [tab, setTab] = useState('problem') + const [setupState, setSetupState] = useState('idle') // idle | running | done | error + const [validating, setValidating] = useState(false) + const [validResult, setValidResult] = useState(null) + const [selectedOption, setSelectedOption] = useState(null) + const [mcqResult, setMcqResult] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [hintsRevealed, setHintsRevealed] = useState([]) + const [copiedCmd, setCopiedCmd] = useState(null) + + // Reset state when scenario changes + useEffect(() => { + setTab('problem') + setSetupState('idle') + setValidResult(null) + setSelectedOption(null) + setMcqResult(null) + setHintsRevealed([]) + if (scenario?.progress?.status === 'completed') { + setSetupState('done') + } + }, [scenario?.id]) + + async function runSetup() { + setSetupState('running') + try { + // Feature 2: teardown first to ensure clean cluster state + await fetch(`/api/scenarios/${scenario.id}/teardown`, { method: 'POST' }).catch(() => {}) + await onScenarioStart?.(scenario.id) + await fetch(`/api/scenarios/${scenario.id}/setup`, { method: 'POST' }) + setSetupState('done') + } catch { + setSetupState('error') + } + } + + async function validate() { + setValidating(true) + setValidResult(null) + try { + const r = await fetch(`/api/scenarios/${scenario.id}/validate`, { method: 'POST' }) + const d = await r.json() + setValidResult(d) + onProgressUpdate() + } catch { + setValidResult({ error: true }) + } + setValidating(false) + } + + async function submitMCQ() { + if (!selectedOption) return + setSubmitting(true) + try { + const r = await fetch(`/api/scenarios/${scenario.id}/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selected: selectedOption }) + }) + const d = await r.json() + setMcqResult(d) + onProgressUpdate() + } catch {} + setSubmitting(false) + } + + function copyCmd(cmd, idx) { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(cmd).then(() => { + setCopiedCmd(idx) + setTimeout(() => setCopiedCmd(null), 1800) + }) + } else { + const textArea = document.createElement("textarea") + textArea.value = cmd + textArea.style.position = "fixed" + textArea.style.left = "-999999px" + textArea.style.top = "-999999px" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + try { + document.execCommand('copy') + setCopiedCmd(idx) + setTimeout(() => setCopiedCmd(null), 1800) + } catch (err) { + console.error('Fallback copy failed', err) + } + textArea.remove() + } + } + + if (!scenario) { + return ( +

+
+
Select a scenario
+
Choose from the left panel to start practising
+
+ ) + } + + const isCompleted = scenario.progress?.status === 'completed' + + return ( +
+ {/* Scenario header */} +
+
+ {scenario.category} + + {scenario.difficulty} + + {scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'} + {scenario.weight} pts +
+
+
{scenario.title}
+ {scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && ( + + )} +
+ {isCompleted && ( +
+ Scenario completed +
+ )} +
+ + {/* Tabs */} +
+ {['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' && !isExamMode ? ['validate'] : [])].map(t => ( + + ))} +
+ + {/* Tab content */} +
+ + {/* PROBLEM TAB */} + {tab === 'problem' && ( +
+ + {/* Setup section (if setup commands exist) */} + {scenario.setup_commands?.length > 0 && ( +
+
+
+ Ready to start? +
+ {setupState === 'idle' && ( + + )} + {setupState === 'running' && ( +
+ Setting up… +
+ )} + {setupState === 'done' && ( + ✓ Environment ready + )} + {setupState === 'error' && ( + + )} +
+
+ Click Start Scenario to provision the lab environment, then solve the challenge below. +
+
+ )} + + {/* Problem description */} +
+ {scenario.description} +
+ + {/* MCQ options */} + {scenario.type === 'mcq' && ( +
+
Select your answer:
+
+ {scenario.options?.map(opt => { + const isSelected = selectedOption === opt.id + const showCorrect = mcqResult && opt.id === mcqResult.correct_option + const showWrong = mcqResult && isSelected && !mcqResult.correct + return ( + + ) + })} +
+ + {!mcqResult ? ( + + ) : ( +
+
+ {mcqResult.correct ? '✓ Correct!' : '✗ Incorrect — see the highlighted answer above'} +
+ {mcqResult.explanation && ( +
+ {mcqResult.explanation} +
+ )} +
+ )} +
+ )} +
+ )} + + {/* HINTS TAB */} + {tab === 'hints' && ( +
+ {scenario.hints?.length === 0 && ( +
No hints available for this scenario.
+ )} + {scenario.hints?.map((hint, i) => { + const revealed = hintsRevealed.includes(i) + return ( +
+
setHintsRevealed(h => revealed ? h.filter(x => x !== i) : [...h, i])}> +
+ Hint {i + 1} + {hint.title} +
+ {revealed ? '▾' : '▸'} +
+ {revealed && ( +
+

+ {hint.body} +

+ {hint.command && ( +
+
{hint.command}
+ +
+ )} +
+ )} +
+ ) + })} +
+ )} + + {/* VALIDATE TAB */} + {tab === 'validate' && scenario.type === 'task' && ( +
+
+
+ {scenario.validation?.description} +
+ +
+ + {validResult && !validResult.error && ( +
+
+ {validResult.passed + ? `✓ All ${validResult.checks.length} checks passed!` + : `${validResult.checks.filter(c => !c.passed).length} of ${validResult.checks.length} checks failed`} + Attempt #{validResult.attempts} +
+ {validResult.checks.map((c, i) => ( +
+ {c.passed ? '✓' : '✗'} +
+
{c.description}
+ {!c.passed && ( +
+ Expected: {c.expected} + Got: {c.actual || '(empty)'} +
+ )} +
+
+ ))} +
+ )} + {validResult?.error && ( +
⚠ Validation failed to run. Is the cluster reachable?
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/ScenarioPanel.module.css b/frontend/src/components/ScenarioPanel.module.css new file mode 100644 index 0000000..354a711 --- /dev/null +++ b/frontend/src/components/ScenarioPanel.module.css @@ -0,0 +1,516 @@ +.panel { + display: flex; + flex-direction: column; + flex: 1; /* fill scenarioWrap entirely */ + min-height: 0; + overflow: hidden; + background: var(--bg); +} + +.empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-3); +} + +.emptyIcon { font-size: 40px; opacity: 0.2; } +.emptyTitle { font-size: 16px; font-weight: 700; color: var(--text-2); } +.emptySub { font-size: 13px; } + +/* Header */ +.scenarioHeader { + padding: 14px 20px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; +} + +.scenarioMeta { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.category { + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-3); +} + +.diff { + font-family: var(--mono); + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 1px solid transparent; +} +.diff.easy { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.diff.medium { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.diff.hard { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); } + +.typeTag { + font-family: var(--mono); + font-size: 10px; + color: var(--blue); + background: var(--blue-dim); + padding: 2px 7px; + border-radius: 3px; + border: 1px solid color-mix(in srgb, currentColor 30%, transparent); +} + +.weight { + font-family: var(--mono); + font-size: 11px; + color: var(--text-3); + margin-left: auto; +} + +.scenarioTitle { + font-size: 19px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.4px; + line-height: 1.3; + flex: 1; +} + +.titleRow { + display: flex; + align-items: flex-start; + gap: 12px; + margin-top: 2px; +} + +.resetBtn { + flex-shrink: 0; + background: var(--amber-dim); + border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent); + border-radius: 6px; + color: var(--amber); + font-size: 12px; + font-family: var(--sans); + font-weight: 700; + padding: 5px 12px; + cursor: pointer; + margin-top: 2px; + white-space: nowrap; + letter-spacing: 0.2px; + transition: color 0.12s, border-color 0.12s, background 0.12s; +} +.resetBtn:hover { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 50%, transparent); + background: var(--red-dim); +} + +.completedBanner { + margin-top: 8px; + font-size: 12px; + font-weight: 600; + color: var(--green); + background: var(--green-dim); + border: 1px solid rgba(57,217,138,0.25); + padding: 4px 10px; + border-radius: 5px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; + padding: 0 16px; +} + +.tab { + padding: 10px 14px; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--text-3); + transition: all 0.12s; + margin-bottom: -1px; +} + +.tab:hover { color: var(--text-2); } + +.activeTab { + color: var(--text); + border-bottom-color: var(--green); +} + +/* Content */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.tabPane { animation: fadeIn 0.2s ease; } + +/* Setup box */ +.setupBox { + background: var(--amber-dim); + border: 1px solid rgba(252,196,25,0.25); + border-radius: var(--radius); + padding: 12px 16px; + margin-bottom: 20px; +} + +.setupHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.setupLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + color: var(--amber); +} + +.setupBtn { + background: var(--amber); + color: #000; + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + transition: opacity 0.15s; +} +.setupBtn:hover { opacity: 0.85; } + +.setupBtnRetry { + composes: setupBtn; + background: var(--red); + color: white; +} + +.setupRunning { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--amber); +} + +.setupDone { font-size: 12px; color: var(--green); font-weight: 600; } + +.setupNote { font-size: 12px; color: var(--text-2); } + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +/* MCQ */ +.mcqSection { margin-top: 24px; } + +.mcqLabel { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: 12px; +} + +.options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } + +.option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + font-family: var(--sans); + font-size: 14px; + color: var(--text); + text-align: left; + transition: all 0.12s; +} + +.option:hover:not(:disabled) { border-color: var(--border2); background: var(--surface2); } + +.option:disabled { cursor: default; } + +.optionSelected { border-color: var(--blue); background: var(--blue-dim); } +.optionCorrect { border-color: var(--green) !important; background: var(--green-dim) !important; } +.optionWrong { border-color: var(--red) !important; background: var(--red-dim) !important; } + +.optionLetter { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1.5px solid var(--border2); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + color: var(--text-2); + flex-shrink: 0; +} + +.optionSelected .optionLetter { border-color: var(--blue); color: var(--blue); background: var(--blue-dim); } +.optionCorrect .optionLetter { border-color: var(--green); color: var(--green); } +.optionWrong .optionLetter { border-color: var(--red); color: var(--red); } + +.optionText { flex: 1; line-height: 1.5; color: var(--text-2); } +.optionMark { font-size: 14px; font-weight: 700; flex-shrink: 0; } +.optionCorrect .optionMark { color: var(--green); } +.optionWrong .optionMark { color: var(--red); } + +.submitBtn { + background: var(--green); + color: #000; + border: none; + border-radius: var(--radius); + padding: 10px 22px; + font-size: 14px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + transition: opacity 0.15s; +} +.submitBtn:hover:not(:disabled) { opacity: 0.85; } +.submitBtn:disabled { opacity: 0.4; cursor: not-allowed; } + +.mcqResult { + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid; +} + +.mcqCorrect { background: var(--green-dim); border-color: rgba(57,217,138,0.3); } +.mcqWrong { background: var(--red-dim); border-color: rgba(255,107,107,0.3); } + +.mcqResultTitle { + font-size: 14px; + font-weight: 700; + margin-bottom: 8px; + color: var(--text); +} + +.mcqExplanation { font-size: 13px; color: var(--text-2); line-height: 1.6; } + +/* Hints */ +.hintCard { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + overflow: hidden; + background: var(--surface); +} + +.hintHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + cursor: pointer; + transition: background 0.12s; +} + +.hintHeader:hover { background: var(--surface2); } + +.hintLeft { display: flex; align-items: center; gap: 10px; } + +.hintNum { + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + color: var(--amber); + background: var(--amber-dim); + padding: 2px 7px; + border-radius: 3px; +} + +.hintTitle { font-size: 13px; font-weight: 600; color: var(--text); } + +.hintChevron { color: var(--text-3); font-size: 12px; } + +.hintBody { + padding: 4px 14px 14px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.hintText { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 10px; margin-top: 10px; } + +.noHints { font-size: 13px; color: var(--text-3); padding: 20px 0; text-align: center; } + +.cmdBlock { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.cmdPre { + padding: 12px 14px; + font-family: var(--mono); + font-size: 12px; + color: var(--green); + line-height: 1.7; + white-space: pre-wrap; + word-break: break-all; + padding-right: 70px; +} + +.copyBtn { + position: absolute; + top: 8px; + right: 8px; + background: var(--surface2); + border: 1px solid var(--border2); + border-radius: 5px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + color: var(--text-2); + cursor: pointer; + font-family: var(--sans); + transition: all 0.12s; +} +.copyBtn:hover { color: var(--text); border-color: var(--green); } + +/* Validate */ +.validateHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} + +.validateDesc { font-size: 13px; color: var(--text-2); line-height: 1.6; flex: 1; } + +.validateBtn { + display: flex; + align-items: center; + gap: 8px; + background: var(--green); + color: #000; + border: none; + border-radius: var(--radius); + padding: 9px 18px; + font-size: 13px; + font-weight: 700; + font-family: var(--sans); + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; + white-space: nowrap; +} +.validateBtn:hover:not(:disabled) { opacity: 0.85; } +.validateBtn:disabled { opacity: 0.5; cursor: not-allowed; } + +.checks { display: flex; flex-direction: column; gap: 6px; } + +.checksSummary { + padding: 10px 14px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.allPassed { background: var(--green-dim); color: var(--green); border: 1px solid rgba(57,217,138,0.3); } +.someFailed { background: var(--red-dim); color: var(--red); border: 1px solid rgba(255,107,107,0.3); } + +.attempts { font-family: var(--mono); font-size: 11px; opacity: 0.7; } + +.check { + display: flex; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + align-items: flex-start; +} + +.checkPass { border-color: rgba(57,217,138,0.2); } +.checkFail { border-color: rgba(255,107,107,0.2); background: rgba(255,107,107,0.03); } + +.checkIcon { + font-size: 13px; + font-weight: 700; + flex-shrink: 0; + margin-top: 1px; +} +.checkPass .checkIcon { color: var(--green); } +.checkFail .checkIcon { color: var(--red); } + +.checkContent { flex: 1; min-width: 0; } +.checkDesc { font-size: 13px; color: var(--text); margin-bottom: 4px; } +.checkDetail { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-3); +} +.checkDetail code { + font-family: var(--mono); + background: var(--surface3); + padding: 1px 5px; + border-radius: 3px; + color: var(--amber); + font-size: 11px; +} + +.validateError { + padding: 12px 14px; + background: var(--amber-dim); + border: 1px solid rgba(252,196,25,0.3); + border-radius: var(--radius); + font-size: 13px; + color: var(--amber); +} + +@keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} } +@keyframes spin { to{transform:rotate(360deg)} } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..cc61a7e --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,210 @@ +import { useState, useMemo } from 'react' +import styles from './Sidebar.module.css' + +const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' } +const TYPE_ICON = { task: '⚙', mcq: '◉' } + +async function resetProgress(scope, opts) { + await fetch('/api/progress/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope, ...opts }), + }) +} + +export default function Sidebar({ + scenarios, activeId, onSelect, loading, + collapsed, onToggleCollapse, width, + activeBundleId, onProgressUpdate, +}) { + const [filterDiff, setFilterDiff] = useState('All') + const [filterType, setFilterType] = useState('All') + + const filteredScenarios = useMemo(() => { + return scenarios.filter(s => { + if (filterDiff !== 'All' && s.difficulty !== filterDiff) return false + if (filterType !== 'All' && s.type !== filterType) return false + return true + }) + }, [scenarios, filterDiff, filterType]) + + const groups = useMemo(() => { + const map = {} + filteredScenarios.forEach(s => { + if (!map[s.category]) map[s.category] = [] + map[s.category].push(s) + }) + return map + }, [filteredScenarios]) + + // Calculate index based on ALL scenarios so numbers stay absolute + const scenarioIndex = useMemo(() => { + const map = {} + scenarios.forEach(s => { + if (!map[s.category]) map[s.category] = [] + map[s.category].push(s) + }) + const idx = {} + let counter = 1 + Object.values(map).forEach(items => { + items.forEach(s => { idx[s.id] = counter++ }) + }) + return idx + }, [scenarios]) + + // Number scenarios in accordion display order (category by category, then by position within category) + + + const [open, setOpen] = useState({}) + + useMemo(() => { + if (!activeId) return + const s = scenarios.find(x => x.id === activeId) + if (s) setOpen(o => ({ ...o, [s.category]: true })) + }, [activeId, scenarios]) + + const toggle = cat => setOpen(o => ({ ...o, [cat]: !o[cat] })) + const totalDone = scenarios.filter(s => s.progress?.status === 'completed').length + + const handleCategoryReset = async (e, cat) => { + e.stopPropagation() + if (!window.confirm(`Reset all progress in "${cat}"?`)) return + await resetProgress('category', { category: cat }) + onProgressUpdate?.() + } + + const handleScenarioReset = async (e, scenarioId, title) => { + e.stopPropagation() + if (!window.confirm(`Reset progress for "${title}"?`)) return + await resetProgress('scenario', { scenarioId }) + onProgressUpdate?.() + } + + return ( +
+ )} + + ) +} diff --git a/frontend/src/components/Sidebar.module.css b/frontend/src/components/Sidebar.module.css new file mode 100644 index 0000000..571ec98 --- /dev/null +++ b/frontend/src/components/Sidebar.module.css @@ -0,0 +1,339 @@ +.sidebar { + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + flex-shrink: 0; + /* width/min-width set via inline style from App.jsx */ +} + +.sidebar.collapsed { overflow: hidden; } + +.sidebarTop { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 10px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + gap: 8px; +} + +.collapseBtn { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-3); + cursor: pointer; + font-size: 18px; + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + flex-shrink: 0; + transition: color 0.12s, background 0.12s; + margin-left: auto; +} +.collapseBtn:hover { color: var(--text); background: var(--surface2); } + +.sidebar.collapsed .sidebarTop { + padding: 14px 0 12px; + justify-content: center; +} +.sidebar.collapsed .collapseBtn { + margin-left: 0; +} + +.sidebarTitle { + font-size: 11px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-3); +} + +.sidebarCount { + font-family: var(--mono); + font-size: 11px; + color: var(--green); + background: var(--green-dim); + padding: 2px 8px; + border-radius: 10px; +} + +.list { + overflow-y: auto; + flex: 1; + padding: 8px 0; +} + +/* Filters */ +.filterBar { + display: flex; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.selectFilter { + flex: 1; + background: var(--surface2); + color: var(--text-2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 6px; + font-size: 11px; + font-family: var(--sans); + outline: none; + cursor: pointer; + transition: all 0.2s ease; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.selectFilter:hover { + background: var(--surface3); + color: var(--text); +} + +.selectFilter.green { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.selectFilter.amber { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.selectFilter.red { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); } + +.selectFilter.task { background: var(--blue-dim); color: var(--blue); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.selectFilter.mcq { background: var(--purple-dim); color: var(--purple); border-color: color-mix(in srgb, currentColor 30%, transparent); } + +/* Skeleton loading */ +.loadingWrap { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; } +.skeleton { + height: 54px; + border-radius: var(--radius); + background: linear-gradient(90deg, var(--surface2) 25%, var(--surface3) 50%, var(--surface2) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} +@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } + +/* Accordion */ +.group { margin-bottom: 2px; } + +.accordion { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: none; + border: none; + cursor: pointer; + color: var(--text-2); + font-family: var(--sans); +} + +.accordion:hover { background: var(--surface2); } +.accordion:hover .catResetBtn { opacity: 1; } + +.accordionLeft { + display: flex; + align-items: center; + gap: 8px; +} + +.chevron { + font-size: 16px; + color: var(--text-3); + transition: transform 0.15s; + display: inline-block; + line-height: 1; +} + +.chevron.open { transform: rotate(90deg); } + +.catName { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text); +} + +.accordionRight { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.catCount { + font-family: var(--mono); + font-size: 11px; + color: var(--text-3); +} + +.catResetBtn { + background: none; + border: none; + cursor: pointer; + font-size: 13px; + color: var(--text-3); + padding: 1px 4px; + border-radius: 4px; + line-height: 1; + transition: color 0.12s, background 0.12s; + flex-shrink: 0; +} +.catResetBtn:hover { + color: var(--red); + background: var(--red-dim); +} + +/* Show catResetBtn on accordion hover too */ +.accordion:hover .catResetBtn { color: var(--text-2); } + +/* Items card — subtle rounded border wrapping each category's scenarios */ +.itemsBox { + margin: 0 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--surface); +} + +.item { + width: 100%; + text-align: left; + background: none; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: 10px 12px; + cursor: pointer; + color: var(--text); + font-family: var(--sans); + transition: background 0.12s; + animation: slideIn 0.15s ease both; +} +.item:last-child { border-bottom: none; } + +.item:hover { background: var(--surface2); } + +.item.active { background: rgba(57,217,138,0.07); } + +.item.done .itemTitle { color: var(--text-2); } + +.itemTop { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; +} + +.itemNum { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + color: var(--text-3); + min-width: 18px; + text-align: right; + flex-shrink: 0; + opacity: 0.7; +} +.item.active .itemNum { color: var(--green); opacity: 1; } + +.typeIcon { + font-size: 12px; + color: var(--text-3); + flex-shrink: 0; + margin-top: 1px; +} + +.item.active .typeIcon { color: var(--green); } + +.itemTitle { + font-size: 13px; + font-weight: 500; + line-height: 1.4; + flex: 1; + color: var(--text); +} + +.checkmark { + color: var(--green); + font-size: 12px; + flex-shrink: 0; + font-weight: 700; +} + +.itemMeta { + display: flex; + align-items: center; + gap: 6px; + padding-left: 20px; +} + +/* Per-scenario inline reset button */ +.itemResetBtn { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + color: var(--text-3); + padding: 1px 4px; + border-radius: 3px; + line-height: 1; + opacity: 0; + transition: opacity 0.12s, color 0.12s, background 0.12s; + margin-left: auto; +} +.item:hover .itemResetBtn { opacity: 1; } +.itemResetBtn:hover { + color: var(--red); + background: var(--red-dim); + opacity: 1; +} + +.diff { + font-family: var(--mono); + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 1px solid transparent; +} + +.diff.green { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.diff.amber { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.diff.red { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); } + +.type { + font-family: var(--mono); + font-size: 10px; + color: var(--text-3); + background: var(--surface3); + padding: 1px 5px; + border-radius: 3px; + font-weight: 600; + border: 1px solid transparent; +} + +.type.task { background: var(--blue-dim); color: var(--blue); border-color: color-mix(in srgb, currentColor 30%, transparent); } +.type.mcq { background: var(--purple-dim); color: var(--purple); border-color: color-mix(in srgb, currentColor 30%, transparent); } + +.weight { + font-family: var(--mono); + font-size: 10px; + color: var(--text-3); + margin-left: auto; +} + +@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} } diff --git a/frontend/src/components/Terminal.jsx b/frontend/src/components/Terminal.jsx new file mode 100644 index 0000000..4248d2c --- /dev/null +++ b/frontend/src/components/Terminal.jsx @@ -0,0 +1,173 @@ +import { useEffect, useRef, useCallback } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import '@xterm/xterm/css/xterm.css' +import styles from './Terminal.module.css' + +// ── xterm.js themes ────────────────────────────────────────────────────────── +const TERM_THEMES = { + dark: { + background: '#0d1117', + foreground: '#e6edf3', + cursor: '#58a6ff', + cursorAccent: '#0d1117', + selectionBackground:'#264f78', + black: '#0d1117', brightBlack: '#6e7681', + red: '#ff7b72', brightRed: '#ffa198', + green: '#3fb950', brightGreen: '#56d364', + yellow: '#d29922', brightYellow: '#e3b341', + blue: '#58a6ff', brightBlue: '#79c0ff', + magenta: '#bc8cff', brightMagenta: '#d2a8ff', + cyan: '#39c5cf', brightCyan: '#56d4dd', + white: '#e6edf3', brightWhite: '#ffffff', + }, + light: { + background: '#f6f8fa', + foreground: '#1f2328', + cursor: '#0969da', + cursorAccent: '#f6f8fa', + selectionBackground:'rgba(84,174,255,0.35)', + black: '#24292f', brightBlack: '#57606a', + red: '#cf222e', brightRed: '#a40e26', + green: '#116329', brightGreen: '#1a7f37', + yellow: '#633c01', brightYellow: '#7d4e00', + blue: '#0969da', brightBlue: '#218bff', + magenta: '#8250df', brightMagenta: '#a475f9', + cyan: '#1b7c83', brightCyan: '#3192aa', + white: '#6e7781', brightWhite: '#8c959f', + }, +} + +function getCurrentTheme() { + return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark' +} + +// ── Component ───────────────────────────────────────────────────────────────── +export default function TerminalComponent({ collapsed, onToggleCollapse }) { + const containerRef = useRef(null) + const termRef = useRef(null) + const fitRef = useRef(null) + const wsRef = useRef(null) + + const fit = useCallback(() => { + const fitAddon = fitRef.current + const term = termRef.current + if (!fitAddon || !term) return + if (!containerRef.current || containerRef.current.offsetHeight === 0) return + try { + fitAddon.fit() + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + } catch {} + }, []) + + const connect = useCallback(() => { + const term = termRef.current + if (!term) return + if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close() } + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const ws = new WebSocket(`${proto}//${location.host}/shell-ws`) + wsRef.current = ws + + ws.onopen = () => { term.clear(); fit() } + ws.onmessage = (e) => { term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)) } + ws.onclose = () => { term.write('\r\n\x1b[33m[Disconnected — click Reconnect]\x1b[0m\r\n') } + ws.onerror = () => { term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n') } + }, [fit]) + + // Re-fit one frame after expand so the CSS height transition has settled + useEffect(() => { + if (!collapsed) { + const id = requestAnimationFrame(() => fit()) + return () => cancelAnimationFrame(id) + } + }, [collapsed, fit]) + + // Mount terminal + useEffect(() => { + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: '"Cascadia Code", "Fira Code", Menlo, Monaco, "Courier New", monospace', + theme: TERM_THEMES[getCurrentTheme()], + scrollback: 5000, + allowTransparency: false, + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.loadAddon(new WebLinksAddon()) + term.open(containerRef.current) + + requestAnimationFrame(() => fitAddon.fit()) + + termRef.current = term + fitRef.current = fitAddon + + term.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data) + }) + + connect() + + window.addEventListener('resize', fit) + const ro = new ResizeObserver(() => fit()) + if (containerRef.current) ro.observe(containerRef.current) + + // Watch and update xterm theme live + const mo = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.attributeName === 'data-theme') { + term.options.theme = TERM_THEMES[getCurrentTheme()] + } + } + }) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + + return () => { + window.removeEventListener('resize', fit) + ro.disconnect() + mo.disconnect() + wsRef.current?.close() + term.dispose() + } + }, [connect, fit]) + + return ( +
+
+
+
+ + + +
+ + $_ + bash — kubekosh + +
+
+ + +
+
+ + {/* + xtermOuter is position:relative so the absolutely-positioned xterm + mount point (containerRef) fills it exactly — canonical xterm.js pattern. + The div stays in the DOM even when collapsed so the PTY session lives. + */} +
+
+
+
+ ) +} diff --git a/frontend/src/components/Terminal.module.css b/frontend/src/components/Terminal.module.css new file mode 100644 index 0000000..0fd9011 --- /dev/null +++ b/frontend/src/components/Terminal.module.css @@ -0,0 +1,91 @@ +.wrap { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background: var(--term-bg); + overflow: hidden; +} + +.bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + height: 36px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.barLeft { + display: flex; + align-items: center; + gap: 12px; +} + +.dots { + display: flex; + align-items: center; + gap: 6px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: block; + opacity: 0.9; +} + +.barTitle { + font-family: var(--mono); + font-size: 12px; + color: var(--text-3); + display: flex; + align-items: center; + gap: 7px; +} + +.barIcon { + color: var(--green); + font-weight: 600; +} + +.barRight { display: flex; gap: 8px; } + +.barBtn { + background: none; + border: 1px solid var(--border); + border-radius: 5px; + padding: 3px 10px; + font-size: 11px; + color: var(--text-3); + cursor: pointer; + font-family: var(--sans); + transition: all 0.12s; +} +.barBtn:hover { color: var(--text-2); border-color: var(--border2); } + +/* + Canonical xterm.js container pattern: + - xtermOuter: flex:1, position:relative → gives FitAddon a reliable size box + - terminal: position:absolute, inset:0 → fills xtermOuter exactly + + This eliminates the black gap caused by xterm canvas not matching its container. +*/ +.xtermOuter { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; + /* height:0 override applied inline when collapsed, keeping DOM alive for PTY */ +} + +.terminal { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5fe141e --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,143 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ── Dark theme (default) ────────────────────────────────────────────────── */ +:root { + --bg: #0c0f14; + --surface: #131720; + --surface2: #1a2030; + --surface3: #212840; + --border: #252d42; + --border2: #2e3a55; + + --green: #39d98a; + --green-dim: rgba(57,217,138,0.12); + --blue: #4dabf7; + --blue-dim: rgba(77,171,247,0.10); + --amber: #fcc419; + --amber-dim: rgba(252,196,25,0.10); + --red: #ff6b6b; + --red-dim: rgba(255,107,107,0.10); + --purple: #9775fa; + --purple-dim: rgba(151,117,250,0.10); + + --text: #e2e8f4; + --text-2: #8899b8; + --text-3: #4d5f80; + + --mono: 'IBM Plex Mono', monospace; + --sans: 'Epilogue', sans-serif; + + --radius: 8px; + --radius-lg: 12px; + + --term-bg: #0d1117; +} + +/* ── Light theme ─────────────────────────────────────────────────────────── */ +:root[data-theme="light"] { + --bg: #f0f4f8; + --surface: #ffffff; + --surface2: #f5f7fa; + --surface3: #eaecf1; + --border: #d6dce8; + --border2: #c0c9d8; + + --green: #0d9955; + --green-dim: rgba(13,153,85,0.10); + --blue: #1976d2; + --blue-dim: rgba(25,118,210,0.10); + --amber: #b45309; + --amber-dim: rgba(180,83,9,0.10); + --red: #dc2626; + --red-dim: rgba(220,38,38,0.10); + --purple: #7c3aed; + --purple-dim: rgba(124,58,237,0.10); + + --text: #111827; + --text-2: #374151; + --text-3: #6b7280; + + --term-bg: #f6f8fa; +} + +html, body, #root { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: var(--sans); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + transition: background 0.25s ease, color 0.25s ease; +} + +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-3); } + +/* Markdown styles */ +.md h1,.md h2,.md h3 { font-family: var(--sans); font-weight: 700; margin-bottom: 12px; line-height: 1.3; } +.md h2 { font-size: 18px; color: var(--text); } +.md h3 { font-size: 15px; color: var(--text-2); } +.md p { margin-bottom: 12px; color: var(--text-2); line-height: 1.7; } +.md p:last-child { margin-bottom: 0; } +.md code { + font-family: var(--mono); + font-size: 12px; + background: var(--surface3); + color: var(--green); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border); +} + +/* Inline code rendered by InlineMd (outside .md wrapper) */ +.inline-code { + font-family: var(--mono); + font-size: 12px; + background: var(--surface3); + color: var(--green); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border); +} +.md pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin: 12px 0; + overflow-x: auto; +} +.md pre code { + background: none; + border: none; + padding: 0; + color: var(--green); + font-size: 12px; + line-height: 1.8; +} +.md ul, .md ol { padding-left: 20px; margin-bottom: 12px; color: var(--text-2); } +.md li { margin-bottom: 4px; line-height: 1.6; } +.md strong { color: var(--text); font-weight: 600; } +.md blockquote { + border-left: 3px solid var(--blue); + padding: 8px 14px; + background: var(--blue-dim); + border-radius: 0 var(--radius) var(--radius) 0; + margin: 12px 0; + color: var(--text-2); + font-style: italic; +} + +/* Animations */ +@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} } +@keyframes spin { to{transform:rotate(360deg)} } +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} } +@keyframes slideIn { from{transform:translateX(-8px);opacity:0} to{transform:translateX(0);opacity:1} } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..87672e3 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +// Apply saved theme before first paint to avoid flash +const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark' +document.documentElement.setAttribute('data-theme', savedTheme) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..06fbdf0 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:4000' + } + }, + build: { + outDir: 'dist' + } +}) diff --git a/scenarios/SCHEMA.md b/scenarios/SCHEMA.md new file mode 100644 index 0000000..0cbea87 --- /dev/null +++ b/scenarios/SCHEMA.md @@ -0,0 +1,211 @@ +# KubeKosh Configuration Schema Reference + +KubeKosh uses two primary JSON files to define its curriculum, learning paths, and exam configurations: +1. **Bundles (`scenarios/bundles.json`)**: Defines the high-level study bundles (e.g., CKA, CKAD, CKS), active highlights, durations for exams, and lists of included scenarios. +2. **Scenarios (`scenarios/scenarios.json`)**: Defines individual exercises, hands-on tasks, multiple-choice questions (MCQs), environment preparations, and automated validation scripts. + +--- + +## 1. Bundles Schema (`scenarios/bundles.json`) + +Bundles are defined as a JSON array of objects. Each bundle organizes a learning track or mock exam. + +### Schema Fields +* **`id`** *(string, required)*: A unique, kebab-case identifier for the bundle (e.g., `k8s-basics`). +* **`name`** *(string, required)*: The human-readable name of the bundle shown in navigation (e.g., `Kubernetes Basics`). +* **`icon`** *(string, required)*: An emoji or glyph representing the bundle (e.g., `🌱`). +* **`tagline`** *(string, required)*: A short summary of the bundle's objectives. +* **`color`** *(string, required)*: Hex color code representing the bundle's UI identity/accent color (e.g., `#3fb950`). +* **`colorDim`** *(string, required)*: Translucent RGBA color matching the accent color at low opacity, used for UI row highlighting (e.g., `rgba(63,185,80,0.12)`). +* **`exam_minutes`** *(number, required)*: The time limit allocated for the mock exam in minutes (e.g., `60`). +* **`scenario_ids`** *(array of strings, required)*: List of scenario IDs belonging to this bundle in the order they should appear. + +### Example Bundle +```json +{ + "id": "k8s-basics", + "name": "Kubernetes Basics", + "icon": "🌱", + "tagline": "Core concepts for beginners", + "color": "#3fb950", + "colorDim": "rgba(63,185,80,0.12)", + "exam_minutes": 60, + "scenario_ids": [ + "pod-basics-mcq", + "kubectl-essentials-mcq", + "namespaces-basics", + "deploy-nginx" + ] +} +``` + +--- + +## 2. Scenarios Schema (`scenarios/scenarios.json`) + +Scenarios are defined as a JSON array of objects. A scenario can be either a hands-on console challenge (`"task"`) or a multiple-choice question (`"mcq"`). + +### Common Fields (All Types) +```jsonc +{ + "id": "unique-kebab-case-id", // string — unique scenario identifier + "title": "Human-readable Title", // string — shown in sidebar list + "category": "Workloads", // string — groups scenarios in sidebar accordion + "difficulty": "Easy", // "Easy" | "Medium" | "Hard" + "type": "task", // "task" | "mcq" + "weight": 7, // number — points value (used for final grade scoring) + "description": "## Markdown...", // string — problem statement supporting GitHub-flavored Markdown + "hints": [...], // array — see Hints schema below + "setup_commands": [...], // array — commands run on environment preparation + "teardown_commands": [...], // array — optional — cleanup commands run after scenario completes + "default_namespace": "default" // string — optional — default active namespace for the terminal +} +``` + +--- + +### Hints Schema +Each hint is rendered as a collapsible card inside the Hints tab of the UI: +```jsonc +{ + "title": "Short title for the hint card", + "body": "Explanation text (plain text, no markdown format).", + "command": "kubectl run nginx --image=nginx" // optional — renders a copyable code block +} +``` + +--- + +### Setup & Teardown Commands +* **`setup_commands`**: Executed sequentially on the Kubernetes cluster when the user starts a scenario or clicks **"Prepare Environment"**. Useful for pre-deploying resources or injecting bugs. +* **`teardown_commands`**: Optional cleanup commands run when moving away from or resetting a scenario. +* Commands must be **objects** with a `command` key: + ```jsonc + "setup_commands": [ + { "command": "kubectl create namespace debug" }, + { "command": "kubectl create deployment broken-app --image=nginx:1.25 -n debug" } + ] + ``` +* *Note:* Non-zero exit codes are tolerated (e.g., "namespace already exists" errors won't halt the pipeline). All commands execute as `root`. + +--- + +### Type: `"task"` — Hands-On Scenario +Requires the user to run shell commands in the interactive terminal. The system runs an automated validation sequence to check the cluster state. + +```jsonc +{ + "type": "task", + "validation": { + "description": "Check that deployment has been correctly configured", + "commands": [ + { + "description": "Checks the running pods count", + "command": "kubectl get deploy nginx -o jsonpath='{.status.readyReplicas}'", + "expected_output": "3", + "match": "exact" // "exact" | "contains" | "regex" + } + ] + } +} +``` + +#### Match Modes +| Mode | Behaviour | +| :--- | :--- | +| `exact` | Trimmed stdout must exactly equal `expected_output`. | +| `contains` | stdout must contain `expected_output` as a substring. | +| `not_contains` | stdout must **not** contain `expected_output` as a substring. | +| `regex` | stdout must match the regular expression in `expected_output`. | + +--- + +### Type: `"mcq"` — Multiple Choice Question +Renders a questionnaire block. No terminal is shown. The user answers by selecting an option. + +```jsonc +{ + "type": "mcq", + "options": [ + { "id": "a", "text": "Option A explanation" }, + { "id": "b", "text": "Option B explanation" }, + { "id": "c", "text": "Option C explanation" }, + { "id": "d", "text": "Option D explanation" } + ], + "correct_option": "c", // must match one of the option IDs + "explanation": "Detailed explanation of why C is the correct answer." // shown after submitting +} +``` + +--- + +## 3. Full Examples + +### Full Example — Hands-On Task Scenario +```json +{ + "id": "scale-deployment", + "title": "Scale a Deployment", + "category": "Workloads", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Scale the Deployment\n\nA deployment named `myapp` exists in the `default` namespace.\n\n**Scale it to 5 replicas.**", + "hints": [ + { + "title": "Using kubectl scale", + "body": "The scale subcommand lets you change the replica count imperatively.", + "command": "kubectl scale deployment myapp --replicas=5" + } + ], + "setup_commands": [ + { "command": "kubectl create deployment myapp --image=nginx:1.25 --replicas=1" } + ], + "teardown_commands": [ + { "command": "kubectl delete deployment myapp --ignore-not-found" } + ], + "default_namespace": "default", + "validation": { + "description": "Checks that myapp has 5 ready replicas.", + "commands": [ + { + "description": "myapp has 5 ready replicas", + "command": "kubectl get deployment myapp -o jsonpath='{.status.readyReplicas}'", + "expected_output": "5", + "match": "exact" + } + ] + } +} +``` + +### Full Example — MCQ Scenario +```json +{ + "id": "service-types-mcq", + "title": "Kubernetes Service Types", + "category": "Networking", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## Kubernetes Service Types\n\nWhich `kubectl` command creates a ClusterIP service named `my-svc` exposing port 80 for a deployment named `my-app`?", + "options": [ + { "id": "a", "text": "kubectl expose deployment my-app --name=my-svc --port=80 --type=ClusterIP" }, + { "id": "b", "text": "kubectl create service my-svc --port=80" }, + { "id": "c", "text": "kubectl apply service my-app --port=80" }, + { "id": "d", "text": "kubectl expose pod my-app --name=my-svc --port=80 --type=NodePort" } + ], + "correct_option": "a", + "explanation": "`kubectl expose deployment` is the correct imperative command. It creates a Service targeting the deployment's pods. `--type=ClusterIP` is the default but explicit here for clarity.", + "hints": [ + { + "title": "kubectl expose syntax", + "body": "Use kubectl expose to create a Service from an existing resource. Specify the resource type, name, port, and service type.", + "command": "kubectl expose deployment my-app --name=my-svc --port=80 --type=ClusterIP" + } + ], + "setup_commands": [], + "teardown_commands": [], + "default_namespace": "default" +} +``` diff --git a/scenarios/bundles.json b/scenarios/bundles.json new file mode 100644 index 0000000..1ad418a --- /dev/null +++ b/scenarios/bundles.json @@ -0,0 +1,146 @@ +[ + { + "id": "k8s-basics", + "name": "Kubernetes Basics", + "icon": "\ud83c\udf31", + "tagline": "Core concepts for beginners", + "color": "#3fb950", + "colorDim": "rgba(63,185,80,0.12)", + "exam_minutes": 60, + "scenario_ids": [ + "pod-basics-mcq", + "kubectl-essentials-mcq", + "labels-selectors-mcq", + "init-containers-mcq", + "namespaces-basics", + "deploy-nginx", + "scale-deployment", + "rolling-update-task", + "liveness-probe-task", + "jobs-cronjobs-mcq", + "daemonset-mcq", + "configmap-volume", + "resource-limits-task", + "env-vars-configmap", + "secrets-basics", + "services-mcq", + "multi-container-pod-basics", + "expose-service-basics", + "pod-labeling-basics", + "extract-logs-basics", + "edit-deployment-basics", + "jsonpath-basics", + "dry-run-manifest-basics", + "delete-by-label-basics", + "kubectl-cp-basics", + "exec-command-basics" + ] + }, + { + "id": "k8s-admin", + "name": "Kubernetes Administrator", + "icon": "\ud83e\uddd1\u200d\u2708\ufe0f", + "tagline": "CKA exam \u2014 cluster administration", + "color": "#58a6ff", + "colorDim": "rgba(88,166,255,0.12)", + "exam_minutes": 120, + "scenario_ids": [ + "rbac-role", + "rbac-clusterrole", + "serviceaccount-pod", + "node-label-selector", + "node-taint-toleration", + "resource-quota-ns", + "limitrange-task", + "etcd-backup-mcq", + "cluster-upgrade-mcq", + "deployment-rollback", + "cronjob-task", + "job-task", + "readiness-probe-task", + "pod-affinity-mcq", + "nodeport-task", + "ingress-task", + "dns-resolution-mcq", + "endpoint-fix-task", + "networkpolicy-egress", + "network-policy", + "storageclass-mcq", + "pvc-dynamic-task", + "emptydir-pod", + "pv-pvc-mount", + "pod-security-context", + "broken-deployment", + "crashloop-fix", + "container-logging-mcq" + ] + }, + { + "id": "k8s-appdev", + "name": "Kubernetes Developer", + "icon": "\ud83d\udee0\ufe0f", + "tagline": "CKAD exam \u2014 application development", + "color": "#bc8cff", + "colorDim": "rgba(188,140,255,0.12)", + "exam_minutes": 120, + "scenario_ids": [ + "pod-basics-mcq", + "kubectl-essentials-mcq", + "namespaces-basics", + "labels-selectors-mcq", + "deploy-nginx", + "scale-deployment", + "rolling-update-task", + "deployment-rollback", + "liveness-probe-task", + "readiness-probe-task", + "configmap-volume", + "env-vars-configmap", + "secrets-basics", + "resource-limits-task", + "cronjob-task", + "job-task", + "emptydir-pod", + "pvc-dynamic-task", + "services-mcq", + "nodeport-task", + "ingress-task", + "rbac-role", + "pod-security-context", + "crashloop-fix", + "container-logging-mcq" + ] + }, + { + "id": "k8s-security", + "name": "Kubernetes Security", + "icon": "\ud83d\udee1", + "tagline": "CKS exam \u2014 hardening and threats", + "color": "#f97316", + "colorDim": "rgba(249,115,22,0.12)", + "exam_minutes": 120, + "scenario_ids": [ + "cks-network-policy", + "cks-pod-security-context", + "cks-rbac-least-privilege", + "cks-seccomp-profile", + "cks-immutable-secret", + "cks-apparmor-profile", + "cks-automount-token", + "cks-network-policy-metadata", + "cks-rbac-clusterrole", + "cks-psa-namespace", + "cks-mcq-runtime-security", + "cks-mcq-api-server", + "cks-mcq-image-footprint", + "cks-mcq-psp-replacement", + "cks-mcq-kubelet-auth", + "cks-readonly-filesystem", + "cks-drop-capabilities", + "cks-tls-ingress", + "cks-image-pull-secret", + "cks-mcq-sandboxing", + "cks-mcq-audit-policy" + ] + } +] \ No newline at end of file diff --git a/scenarios/scenarios.json b/scenarios/scenarios.json new file mode 100644 index 0000000..0e4daef --- /dev/null +++ b/scenarios/scenarios.json @@ -0,0 +1,3498 @@ +[ + { + "id": "pod-basics-mcq", + "title": "Pod Lifecycle States", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## What does the `CrashLoopBackOff` status mean for a Pod?\n\nYou observe the following when running `kubectl get pods`:\n\n```\nNAME READY STATUS RESTARTS AGE\nmy-app-xyz 0/1 CrashLoopBackOff 5 3m\n```\n\nWhat is the Kubernetes control plane communicating with this status?", + "options": [ + { + "id": "a", + "text": "The pod image could not be pulled from the container registry" + }, + { + "id": "b", + "text": "The container starts, crashes, and Kubernetes keeps restarting it with exponential backoff delay" + }, + { + "id": "c", + "text": "The pod is waiting for a PersistentVolume to become available" + }, + { + "id": "d", + "text": "The pod has been evicted from the node due to resource pressure" + } + ], + "correct_option": "b", + "explanation": "`CrashLoopBackOff` means the container is repeatedly crashing after startup. Kubernetes restarts it automatically but introduces increasing delays (backoff) between attempts to avoid overwhelming the system. Common causes include a bad entrypoint command, missing environment variables, or application errors on startup.", + "hints": [ + { + "title": "Interpreting pod status", + "body": "Use `kubectl describe pod ` to see the Events section — it shows exactly why the container is failing.", + "command": "kubectl describe pod my-app-xyz" + }, + { + "title": "Reading container logs", + "body": "Even a crashed container leaves logs behind. Use `--previous` to read the logs from the last crash.", + "command": "kubectl logs my-app-xyz --previous" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "deploy-nginx", + "title": "Deploy and Expose Nginx", + "category": "Workloads", + "difficulty": "Easy", + "type": "task", + "weight": 5, + "description": "## Deploy and Expose an Nginx Application\n\nThe platform team needs a simple web server running in the cluster.\n\n**Your tasks:**\n1. Create a **Deployment** named `webserver` in the `default` namespace\n2. Use the image `nginx:1.25`\n3. Set replica count to **2**\n4. Expose it via a **ClusterIP Service** named `webserver-svc` on port **80**\n\n> 💡 Tip: You can use `kubectl create` for both resources imperatively, or write YAML manifests.", + "hints": [ + { + "title": "Create the Deployment", + "body": "Use `kubectl create deployment` with the `--image` and `--replicas` flags.", + "command": "kubectl create deployment webserver --image=nginx:1.25 --replicas=2" + }, + { + "title": "Expose the Deployment", + "body": "Use `kubectl expose` to create a ClusterIP service targeting port 80.", + "command": "kubectl expose deployment webserver --name=webserver-svc --port=80 --target-port=80" + }, + { + "title": "Verify", + "body": "Check that both resources are up and the service endpoints are populated.", + "command": "kubectl get deployment webserver && kubectl get svc webserver-svc && kubectl get endpoints webserver-svc" + } + ], + "setup_commands": [], + "validation": { + "description": "Checks that the deployment exists with 2 replicas, uses the correct image, and the service exists targeting port 80.", + "commands": [ + { + "description": "Deployment 'webserver' exists", + "command": "kubectl get deployment webserver -o jsonpath='{.metadata.name}'", + "expected_output": "webserver", + "match": "exact" + }, + { + "description": "Deployment has 2 ready replicas", + "command": "kubectl get deployment webserver -o jsonpath='{.status.readyReplicas}'", + "expected_output": "2", + "match": "exact" + }, + { + "description": "Deployment uses nginx:1.25 image", + "command": "kubectl get deployment webserver -o jsonpath='{.spec.template.spec.containers[0].image}'", + "expected_output": "nginx:1.25", + "match": "exact" + }, + { + "description": "Service 'webserver-svc' exists", + "command": "kubectl get svc webserver-svc -o jsonpath='{.metadata.name}'", + "expected_output": "webserver-svc", + "match": "exact" + }, + { + "description": "Service exposes port 80", + "command": "kubectl get svc webserver-svc -o jsonpath='{.spec.ports[0].port}'", + "expected_output": "80", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment webserver --ignore-not-found" + }, + { + "command": "kubectl delete svc webserver-svc --ignore-not-found" + } + ] + }, + { + "id": "configmap-volume", + "title": "Mount ConfigMap as Volume", + "category": "Configuration", + "difficulty": "Medium", + "type": "task", + "weight": 7, + "description": "## Inject Configuration via a ConfigMap Volume\n\nAn application reads its configuration from files on disk at `/etc/app-config/`.\n\n**Your tasks:**\n1. Create a **ConfigMap** named `app-config` in the `default` namespace with the following key-value pairs:\n - `environment`: `production`\n - `log_level`: `warn`\n - `max_connections`: `200`\n2. Create a **Pod** named `config-reader` using image `busybox:1.36`\n3. Mount the ConfigMap as a **volume** at `/etc/app-config` inside the container\n4. The pod should run the command: `sleep 3600`", + "hints": [ + { + "title": "Create the ConfigMap", + "body": "Use `--from-literal` for each key-value pair.", + "command": "kubectl create configmap app-config --from-literal=environment=production --from-literal=log_level=warn --from-literal=max_connections=200" + }, + { + "title": "Write the Pod manifest", + "body": "Use a heredoc piped directly to kubectl apply -f - to avoid writing to a temp file.", + "command": "cat < Start by describing the deployment and its pods to find clues about what's wrong.", + "hints": [ + { + "title": "Inspect the deployment", + "body": "Start by listing pods in the debug namespace and describing the failing ones.", + "command": "kubectl get pods -n debug\nkubectl describe pod -n debug -l app=broken-app" + }, + { + "title": "Check events", + "body": "The Events section in `kubectl describe` usually tells you exactly what's wrong — look for ImagePullBackOff, OOMKilled, or probe failures.", + "command": "kubectl describe deployment broken-app -n debug" + }, + { + "title": "Fix the image tag", + "body": "If the image tag doesn't exist, update it to a valid one using `kubectl set image`.", + "command": "kubectl set image deployment/broken-app app=nginx:1.25 -n debug" + } + ], + "setup_commands": [ + { + "command": "kubectl create namespace debug" + }, + { + "command": "kubectl create deployment broken-app --image=nginx:invalid-tag-99999 --replicas=3 -n debug" + } + ], + "validation": { + "description": "Checks that the broken-app deployment in the debug namespace has 3 ready replicas.", + "commands": [ + { + "description": "Deployment 'broken-app' has 3 ready replicas", + "command": "kubectl get deployment broken-app -n debug -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -v '^$' || echo 0", + "expected_output": "3", + "match": "exact" + }, + { + "description": "No pods in ImagePullBackOff state", + "command": "kubectl get pods -n debug --no-headers 2>/dev/null | awk '{print $3}' | grep -c 'ImagePullBackOff\\|ErrImagePull' || true", + "expected_output": "0", + "match": "exact" + } + ] + }, + "default_namespace": "debug", + "teardown_commands": [ + { + "command": "kubectl delete namespace debug --ignore-not-found --wait=false" + } + ] + }, + { + "id": "pv-pvc-mount", + "title": "Persistent Storage with PV and PVC", + "category": "Storage", + "difficulty": "Medium", + "type": "task", + "weight": 7, + "description": "## Attach Persistent Storage to a Pod\n\nA stateful application needs data to survive pod restarts.\n\n**Your tasks:**\n1. Create a **PersistentVolumeClaim** named `local-pvc` in the `default` namespace requesting `200Mi` with:\n - Access mode: `ReadWriteOnce`\n - StorageClass: `local-path` (available in this cluster via k3s)\n2. Create a **Pod** named `storage-pod` using image `nginx:1.25` that mounts the PVC at `/data`\n3. Write a file inside the pod at `/data/hello.txt` with content `hello-k8s`", + "hints": [ + { + "title": "Create the PVC", + "body": "Use storageClassName: local-path to use the k3s built-in dynamic provisioner. No PV needs to be created manually.", + "command": "cat < /data/hello.txt'" + } + ], + "setup_commands": [], + "validation": { + "description": "Validates PV, PVC binding, pod running, and file written to the volume.", + "commands": [ + { + "description": "PVC local-pvc is Bound", + "command": "kubectl get pvc local-pvc -o jsonpath='{.status.phase}'", + "expected_output": "Bound", + "match": "exact" + }, + { + "description": "Pod 'storage-pod' is Running", + "command": "kubectl get pod storage-pod -o jsonpath='{.status.phase}'", + "expected_output": "Running", + "match": "exact" + }, + { + "description": "File /data/hello.txt contains 'hello-k8s'", + "command": "kubectl exec storage-pod -- cat /data/hello.txt", + "expected_output": "hello-k8s", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pod storage-pod --ignore-not-found --grace-period=0 --force" + }, + { + "command": "kubectl delete pvc local-pvc --ignore-not-found" + }, + { + "command": "kubectl delete pv local-pv --ignore-not-found" + } + ] + }, + { + "id": "network-policy", + "title": "Isolate Traffic with NetworkPolicy", + "category": "Networking", + "difficulty": "Hard", + "type": "task", + "weight": 9, + "description": "## Implement Network Isolation\n\nA pre-created `database` pod in the `netpol` namespace must only accept traffic from pods labeled `role=backend`.\n\n**Your tasks:**\n1. Create a **NetworkPolicy** named `db-isolate` in the `netpol` namespace that:\n - Targets pods with label `app=database`\n - Allows **ingress** only from pods with label `role=backend` in the **same namespace**\n - Denies all other ingress traffic\n2. Verify the policy is applied correctly\n\n> The `database` and `backend` pods are pre-created for you.", + "hints": [ + { + "title": "Understand NetworkPolicy selectors", + "body": "A NetworkPolicy uses `podSelector` to pick which pods it applies to, and `ingress.from` to define allowed sources.", + "command": "kubectl explain networkpolicy.spec.ingress.from" + }, + { + "title": "Write the NetworkPolicy", + "body": "An empty ingress rule (no `from`) denies everything. Specifying a `from` allows only those sources.", + "command": "cat <` to create a new namespace.", + "command": "kubectl create namespace team-alpha" + }, + { + "title": "Deploy into a specific namespace", + "body": "Use the `-n` or `--namespace` flag with `kubectl create deployment`. Alternatively use `kubectl apply -f` with `namespace` set in the manifest metadata.", + "command": "kubectl create deployment web --image=nginx:alpine --replicas=2 -n team-alpha" + } + ], + "setup_commands": [], + "validation": { + "commands": [ + { + "description": "Namespace 'team-alpha' exists", + "command": "kubectl get namespace team-alpha -o jsonpath='{.metadata.name}'", + "expected_output": "team-alpha", + "match": "exact" + }, + { + "description": "Deployment 'web' exists in team-alpha", + "command": "kubectl get deployment web -n team-alpha -o jsonpath='{.metadata.name}'", + "expected_output": "web", + "match": "exact" + }, + { + "description": "Deployment 'web' has 2 replicas", + "command": "kubectl get deployment web -n team-alpha -o jsonpath='{.spec.replicas}'", + "expected_output": "2", + "match": "exact" + }, + { + "description": "Deployment uses nginx image", + "command": "kubectl get deployment web -n team-alpha -o jsonpath='{.spec.template.spec.containers[0].image}'", + "expected_output": "nginx", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete namespace team-alpha --ignore-not-found --wait=false" + } + ] + }, + { + "id": "labels-selectors-mcq", + "title": "Labels and Selectors", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## Labels and Selectors\n\nYou run the following command:\n\n```bash\nkubectl get pods -l env=prod,tier=frontend\n```\n\nWhat does this command return?", + "options": [ + { + "id": "a", + "text": "Pods that have BOTH `env=prod` AND `tier=frontend` labels" + }, + { + "id": "b", + "text": "Pods that have either `env=prod` OR `tier=frontend` labels" + }, + { + "id": "c", + "text": "Pods that do NOT have `env=prod` or `tier=frontend` labels" + }, + { + "id": "d", + "text": "All pods, sorted by the `env` and `tier` label values" + } + ], + "correct_option": "a", + "explanation": "When you specify multiple label selector expressions separated by commas, Kubernetes applies a logical **AND** — all conditions must be true. Only pods that have both `env=prod` **and** `tier=frontend` are returned. Use `-l 'env in (prod,staging)'` for OR-style matching.", + "hints": [ + { + "title": "How comma-separated selectors work", + "body": "The `-l` flag accepts a comma-separated list of `key=value` expressions. Multiple expressions are ANDed together — all must match for a pod to be included in the result.", + "command": "kubectl get pods -l env=prod,tier=frontend" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "resource-limits-task", + "title": "Resource Requests and Limits", + "category": "Configuration", + "difficulty": "Easy", + "type": "task", + "weight": 5, + "description": "## Resource Requests and Limits\n\nProper resource management prevents noisy-neighbour issues and enables the Kubernetes scheduler to make good placement decisions.\n\n**Your task:**\n\nCreate a Pod named `limited-pod` using the `nginx:alpine` image with the following resource configuration:\n\n| | CPU | Memory |\n|---|---|---|\n| **Request** | `100m` | `64Mi` |\n| **Limit** | `200m` | `128Mi` |\n\n```bash\n# Tip: write a manifest and apply it\nkubectl apply -f limited-pod.yaml\n```", + "hints": [ + { + "title": "Pod manifest with resources", + "body": "Add a `resources` block under `spec.containers[].resources` with `requests` and `limits` sub-keys.", + "command": "cat < =\n```", + "hints": [ + { + "title": "Set a new image on a Deployment", + "body": "Use `kubectl set image deployment/ =` to trigger a rolling update.", + "command": "kubectl set image deployment/app-v1 nginx=nginx:1.25" + }, + { + "title": "Check rollout progress", + "body": "Monitor the rollout with `kubectl rollout status`. Once complete, all pods will run the new image.", + "command": "kubectl get deployment app-v1 -o jsonpath='{.spec.template.spec.containers[0].image}'" + } + ], + "setup_commands": [ + { + "command": "kubectl create deployment app-v1 --image=nginx:1.24 --replicas=2" + }, + { + "command": "kubectl rollout status deployment/app-v1 --timeout=60s" + } + ], + "validation": { + "commands": [ + { + "description": "Deployment 'app-v1' image is nginx:1.25", + "command": "kubectl get deployment app-v1 -o jsonpath='{.spec.template.spec.containers[0].image}'", + "expected_output": "nginx:1.25", + "match": "exact" + }, + { + "description": "Deployment has 2 ready replicas", + "command": "kubectl get deployment app-v1 -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -v '^$' || echo 0", + "expected_output": "2", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment app-v1 --ignore-not-found" + } + ] + }, + { + "id": "init-containers-mcq", + "title": "Init Containers Behaviour", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## Init Containers Behaviour\n\nA pod spec defines two init containers (`init-db-check` and `init-config`) followed by one main app container (`web`).\n\nWhich statement **correctly** describes how Kubernetes runs these containers?", + "options": [ + { + "id": "a", + "text": "All three containers start simultaneously; init containers simply have lower priority" + }, + { + "id": "b", + "text": "Init containers run sequentially to completion before the main `web` container starts" + }, + { + "id": "c", + "text": "The `web` container starts first, and init containers run as sidecars alongside it" + }, + { + "id": "d", + "text": "Init containers cannot share volumes with the main container" + } + ], + "correct_option": "b", + "explanation": "Init containers always run **sequentially** and must each exit with a success (exit code 0) before the next one starts. Only after **all** init containers complete successfully does Kubernetes start the main application containers. They can share volumes with main containers, making them ideal for setup tasks like seeding configs, waiting for dependencies, or initialising databases.", + "hints": [ + { + "title": "Init container execution order", + "body": "Check `kubectl explain pod.spec.initContainers`. Each init container runs to completion before the next begins, and all must succeed before app containers start.", + "command": "kubectl explain pod.spec.initContainers" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "env-vars-configmap", + "title": "Environment Variables from ConfigMap", + "category": "Configuration", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Environment Variables from ConfigMap\n\nConfigMaps can inject configuration as environment variables into pods, keeping application images generic and portable.\n\n**Your task:**\n\n1. Create a ConfigMap named `app-env` with two keys:\n - `LOG_LEVEL=debug`\n - `APP_PORT=8080`\n\n2. Create a Pod named `env-pod` using `busybox:1.36` with command `sleep 3600` that loads **all keys** from `app-env` as environment variables using `envFrom`.\n\n**Verify:**\n```bash\nkubectl exec env-pod -- env | grep -E 'LOG_LEVEL|APP_PORT'\n```", + "hints": [ + { + "title": "Create the ConfigMap", + "body": "Use `kubectl create configmap` with multiple `--from-literal` flags.", + "command": "kubectl create configmap app-env --from-literal=LOG_LEVEL=debug --from-literal=APP_PORT=8080" + }, + { + "title": "Load all ConfigMap keys via envFrom", + "body": "Use `envFrom` with `configMapRef` to load all keys at once — simpler than mapping each key individually.", + "command": "cat </dev/null | grep -v '^$' || echo 0", + "expected_output": "4", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment webapp --ignore-not-found" + } + ] + }, + { + "id": "kubectl-essentials-mcq", + "title": "Essential kubectl Commands", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "mcq", + "weight": 2, + "description": "## Essential kubectl Commands\n\nYou need to quickly view the **logs** of a container named `api` inside a pod named `backend-7d9f`. The pod has multiple containers.\n\nWhich command is correct?", + "options": [ + { + "id": "a", + "text": "`kubectl describe pod backend-7d9f`" + }, + { + "id": "b", + "text": "`kubectl logs backend-7d9f`" + }, + { + "id": "c", + "text": "`kubectl logs backend-7d9f -c api`" + }, + { + "id": "d", + "text": "`kubectl exec backend-7d9f -- cat /var/log/api.log`" + } + ], + "correct_option": "c", + "explanation": "When a pod has **multiple containers**, you must specify which container's logs you want using the `-c ` flag: `kubectl logs -c `. Without `-c`, kubectl returns an error if the pod has more than one container. `kubectl describe` shows metadata and events, not live logs. `kubectl exec` can work but is cumbersome and container-runtime dependent.", + "hints": [ + { + "title": "kubectl logs flags", + "body": "Key flags: `-c` (container name for multi-container pods), `-f` (follow/stream), `--previous` (crashed container logs), `--since=1h` (time filter).", + "command": "kubectl logs --help | head -30" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "rbac-clusterrole", + "title": "ClusterRole and ClusterRoleBinding", + "category": "Cluster Administration", + "difficulty": "Medium", + "type": "task", + "weight": 8, + "description": "## ClusterRole and ClusterRoleBinding\n\nRBAC **ClusterRoles** grant permissions cluster-wide (across all namespaces), unlike Roles which are namespace-scoped.\n\n**Your task:**\n\n1. Create a **ClusterRole** named `pod-reader` that allows `get`, `list`, `watch` on `pods`\n2. Create a **ClusterRoleBinding** named `pod-reader-binding` that binds `pod-reader` to the ServiceAccount `default` in the `default` namespace\n\n```bash\nkubectl create clusterrole --help\nkubectl create clusterrolebinding --help\n```", + "hints": [ + { + "title": "Create the ClusterRole", + "body": "Use `kubectl create clusterrole` with `--verb` and `--resource` flags.", + "command": "kubectl create clusterrole pod-reader --verb=get,list,watch --resource=pods" + }, + { + "title": "Create the ClusterRoleBinding", + "body": "Bind the ClusterRole to a ServiceAccount using `--serviceaccount=namespace:name`.", + "command": "kubectl create clusterrolebinding pod-reader-binding --clusterrole=pod-reader --serviceaccount=default:default" + } + ], + "setup_commands": [], + "validation": { + "commands": [ + { + "description": "ClusterRole 'pod-reader' exists", + "command": "kubectl get clusterrole pod-reader -o jsonpath='{.metadata.name}'", + "expected_output": "pod-reader", + "match": "exact" + }, + { + "description": "ClusterRole allows 'get' on pods", + "command": "kubectl get clusterrole pod-reader -o jsonpath='{.rules[0].verbs[*]}'", + "expected_output": "get", + "match": "contains" + }, + { + "description": "ClusterRoleBinding 'pod-reader-binding' exists", + "command": "kubectl get clusterrolebinding pod-reader-binding -o jsonpath='{.metadata.name}'", + "expected_output": "pod-reader-binding", + "match": "exact" + }, + { + "description": "Binding references the 'pod-reader' ClusterRole", + "command": "kubectl get clusterrolebinding pod-reader-binding -o jsonpath='{.roleRef.name}'", + "expected_output": "pod-reader", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete clusterrole pod-reader --ignore-not-found" + }, + { + "command": "kubectl delete clusterrolebinding pod-reader-binding --ignore-not-found" + } + ] + }, + { + "id": "node-label-selector", + "title": "Node Labels and nodeSelector", + "category": "Cluster Administration", + "difficulty": "Easy", + "type": "task", + "weight": 5, + "description": "## Node Labels and nodeSelector\n\nLabelling nodes lets you constrain which nodes a pod can be scheduled on, using `nodeSelector` in the pod spec.\n\n**Your task:**\n\n1. Add the label `disk=ssd` to node `k8s-lab`\n2. Create a Pod named `ssd-pod` using `nginx:alpine` that uses `nodeSelector` to target nodes with `disk=ssd`", + "hints": [ + { + "title": "Label the node", + "body": "Use `kubectl label node key=value`.", + "command": "kubectl label node k8s-lab disk=ssd --overwrite" + }, + { + "title": "Use nodeSelector in pod spec", + "body": "Add `nodeSelector: disk: ssd` under `spec` in the pod manifest.", + "command": "cat </dev/null || true" + }, + { + "command": "kubectl delete pod ssd-pod --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "node-taint-toleration", + "title": "Taints and Tolerations", + "category": "Cluster Administration", + "difficulty": "Medium", + "type": "task", + "weight": 7, + "description": "## Taints and Tolerations\n\nTaints allow a node to **repel** pods. A toleration on a pod allows it to be scheduled onto a tainted node.\n\nThe node `k8s-lab` has been tainted with `env=gpu:NoSchedule`.\n\n**Your task:**\n\nCreate a Pod named `gpu-pod` using `nginx:alpine` that tolerates the taint `env=gpu:NoSchedule` so it can be scheduled on this node.\n\n```bash\nkubectl describe node k8s-lab | grep -A5 Taints\n```", + "hints": [ + { + "title": "Toleration structure", + "body": "Add a `tolerations` block under `spec`. Match `key`, `operator`, `value`, and `effect` to the node taint.", + "command": "cat </dev/null || true" + } + ], + "validation": { + "commands": [ + { + "description": "Node has taint env=gpu:NoSchedule", + "command": "kubectl get node k8s-lab -o jsonpath='{.spec.taints[*].key}'", + "expected_output": "env", + "match": "contains" + }, + { + "description": "Pod 'gpu-pod' has toleration for key 'env'", + "command": "kubectl get pod gpu-pod -o jsonpath='{.spec.tolerations[*].key}'", + "expected_output": "env", + "match": "contains" + }, + { + "description": "Pod 'gpu-pod' toleration effect is NoSchedule", + "command": "kubectl get pod gpu-pod -o jsonpath='{.spec.tolerations[*].effect}'", + "expected_output": "NoSchedule", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl taint nodes k8s-lab env:NoSchedule- 2>/dev/null || true" + }, + { + "command": "kubectl delete pod gpu-pod --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "etcd-backup-mcq", + "title": "etcd Backup and Restore", + "category": "Cluster Administration", + "difficulty": "Hard", + "type": "mcq", + "weight": 5, + "description": "## etcd Backup and Restore\n\nIn the CKA exam, you may need to back up and restore an etcd cluster. Which command correctly creates an etcd snapshot?\n\n```bash\n# The etcdctl binary is available on the control-plane node.\n# Assume certificates are at /etc/kubernetes/pki/etcd/\n```", + "options": [ + { + "id": "a", + "text": "`etcdctl snapshot save /backup/etcd.db` (no flags needed — etcdctl auto-detects certs)" + }, + { + "id": "b", + "text": "`ETCDCTL_API=3 etcdctl snapshot save /backup/etcd.db --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key`" + }, + { + "id": "c", + "text": "`kubectl exec etcd-controlplane -- etcdctl backup --data-dir=/var/lib/etcd`" + }, + { + "id": "d", + "text": "`etcdctl snapshot save /backup/etcd.db --kubeconfig=/root/.kube/config`" + } + ], + "correct_option": "b", + "explanation": "etcd requires **TLS certificates** and explicit endpoint specification. You must set `ETCDCTL_API=3`, point to the etcd endpoint (`https://127.0.0.1:2379` on control-plane), and provide the CA cert, server cert, and key from `/etc/kubernetes/pki/etcd/`. To restore: `ETCDCTL_API=3 etcdctl snapshot restore /backup/etcd.db --data-dir=/var/lib/etcd-restored`, then update the etcd static pod manifest to point to the new data dir.", + "hints": [ + { + "title": "etcdctl API version", + "body": "Always set `ETCDCTL_API=3`. etcdctl v2 and v3 APIs are different — the CKA exam uses v3. The certs live at `/etc/kubernetes/pki/etcd/`.", + "command": "ETCDCTL_API=3 etcdctl snapshot status /backup/etcd.db --write-out=table" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "resource-quota-ns", + "title": "ResourceQuota for a Namespace", + "category": "Cluster Administration", + "difficulty": "Medium", + "type": "task", + "weight": 7, + "description": "## ResourceQuota for a Namespace\n\nResourceQuotas enforce aggregate resource constraints per namespace, preventing any single team from consuming all cluster resources.\n\n**Your task:**\n\n1. Create a namespace named `team-quota`\n2. Create a **ResourceQuota** named `compute-quota` in `team-quota` that limits:\n - `pods`: `5`\n - `requests.cpu`: `\"1\"`\n - `requests.memory`: `\"500Mi\"`", + "hints": [ + { + "title": "Create the ResourceQuota", + "body": "Use `kubectl create resourcequota` with `--hard` flag or apply a YAML manifest.", + "command": "cat </dev/null || true" + } + ], + "validation": { + "commands": [ + { + "description": "ResourceQuota 'compute-quota' exists in team-quota", + "command": "kubectl get resourcequota compute-quota -n team-quota -o jsonpath='{.metadata.name}'", + "expected_output": "compute-quota", + "match": "exact" + }, + { + "description": "Pod limit is 5", + "command": "kubectl get resourcequota compute-quota -n team-quota -o jsonpath='{.spec.hard.pods}'", + "expected_output": "5", + "match": "exact" + }, + { + "description": "CPU request limit is 1", + "command": "kubectl get resourcequota compute-quota -n team-quota -o jsonpath='{.spec.hard.requests\\.cpu}'", + "expected_output": "1", + "match": "exact" + } + ] + }, + "default_namespace": "team-quota", + "teardown_commands": [ + { + "command": "kubectl delete namespace team-quota --ignore-not-found --wait=false" + } + ] + }, + { + "id": "deployment-rollback", + "title": "Rolling Back a Deployment", + "category": "Workloads & Scheduling", + "difficulty": "Easy", + "type": "task", + "weight": 5, + "description": "## Rolling Back a Deployment\n\nA deployment `api-server` was mistakenly updated to a broken image. Your task is to roll it back to the previous working revision.\n\n**Your task:**\n\nRoll back the `api-server` deployment to its previous revision.\n\n```bash\n# Check rollout history\nkubectl rollout history deployment/api-server\n# Rollback\nkubectl rollout undo deployment/api-server\n```", + "hints": [ + { + "title": "Undo a rollout", + "body": "`kubectl rollout undo deployment/` reverts to the previous revision. Use `--to-revision=N` to go to a specific revision number.", + "command": "kubectl rollout undo deployment/api-server" + } + ], + "setup_commands": [ + { + "command": "kubectl create deployment api-server --image=nginx:1.24 --replicas=2 2>/dev/null || true" + }, + { + "command": "kubectl rollout status deployment/api-server --timeout=60s" + }, + { + "command": "kubectl set image deployment/api-server nginx=nginx:broken-image" + } + ], + "validation": { + "commands": [ + { + "description": "Deployment 'api-server' image rolled back to nginx:1.24", + "command": "kubectl get deployment api-server -o jsonpath='{.spec.template.spec.containers[0].image}'", + "expected_output": "nginx:1.24", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment api-server --ignore-not-found" + } + ] + }, + { + "id": "cronjob-task", + "title": "Create a CronJob", + "category": "Workloads & Scheduling", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Create a CronJob\n\nCronJobs create Jobs on a repeating schedule, perfect for maintenance tasks like log rotation, backups, or report generation.\n\n**Your task:**\n\nCreate a CronJob named `date-printer` that:\n- Runs **every minute** (`* * * * *`)\n- Uses `busybox:1.36`\n- Executes: `date`\n- Has `successfulJobsHistoryLimit: 3`", + "hints": [ + { + "title": "CronJob manifest", + "body": "Use `kubectl create cronjob` or apply a manifest. The schedule uses standard cron syntax.", + "command": "kubectl create cronjob date-printer --image=busybox:1.36 --schedule='* * * * *' -- date" + } + ], + "setup_commands": [], + "validation": { + "commands": [ + { + "description": "CronJob 'date-printer' exists", + "command": "kubectl get cronjob date-printer -o jsonpath='{.metadata.name}'", + "expected_output": "date-printer", + "match": "exact" + }, + { + "description": "CronJob schedule is every minute", + "command": "kubectl get cronjob date-printer -o jsonpath='{.spec.schedule}'", + "expected_output": "* * * * *", + "match": "exact" + }, + { + "description": "CronJob uses busybox image", + "command": "kubectl get cronjob date-printer -o jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}'", + "expected_output": "busybox", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete cronjob date-printer --ignore-not-found" + } + ] + }, + { + "id": "readiness-probe-task", + "title": "Configure a Readiness Probe", + "category": "Workloads & Scheduling", + "difficulty": "Medium", + "type": "task", + "weight": 6, + "description": "## Configure a Readiness Probe\n\nA **readiness probe** tells Kubernetes when a container is ready to accept traffic. Unlike a liveness probe (which restarts containers), a failed readiness probe removes the pod from Service endpoints until it recovers.\n\n**Your task:**\n\nCreate a Deployment named `ready-app` with:\n- Image: `nginx:alpine`, 2 replicas\n- A **readiness probe**: HTTP GET `/` on port `80`, `initialDelaySeconds: 3`, `periodSeconds: 5`", + "hints": [ + { + "title": "Readiness probe YAML", + "body": "Add `readinessProbe` under the container spec — same structure as `livenessProbe` but controls traffic routing, not restarts.", + "command": "cat </dev/null || true" + } + ], + "validation": { + "commands": [ + { + "description": "Service 'frontend-np' exists", + "command": "kubectl get service frontend-np -o jsonpath='{.metadata.name}'", + "expected_output": "frontend-np", + "match": "exact" + }, + { + "description": "Service type is NodePort", + "command": "kubectl get service frontend-np -o jsonpath='{.spec.type}'", + "expected_output": "NodePort", + "match": "exact" + }, + { + "description": "NodePort is 30080", + "command": "kubectl get service frontend-np -o jsonpath='{.spec.ports[0].nodePort}'", + "expected_output": "30080", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment frontend --ignore-not-found" + }, + { + "command": "kubectl delete svc frontend-np --ignore-not-found" + } + ] + }, + { + "id": "dns-resolution-mcq", + "title": "Kubernetes DNS Resolution", + "category": "Services & Networking", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## Kubernetes DNS Resolution\n\nA pod in namespace `frontend` needs to reach a service named `db-service` in namespace `backend`. Which DNS name correctly resolves to that service from within the cluster?", + "options": [ + { + "id": "a", + "text": "`db-service`" + }, + { + "id": "b", + "text": "`db-service.backend`" + }, + { + "id": "c", + "text": "`db-service.backend.svc.cluster.local`" + }, + { + "id": "d", + "text": "`backend.db-service.cluster.local`" + } + ], + "correct_option": "c", + "explanation": "The full Kubernetes DNS format is `..svc.`. The default cluster domain is `cluster.local`, so the FQDN is `db-service.backend.svc.cluster.local`. When pods are in the **same namespace**, just `db-service` works. Across namespaces, you need at minimum `db-service.backend` (short form). Option C is the fully-qualified name that always works regardless of the caller's namespace.", + "hints": [ + { + "title": "Kubernetes DNS format", + "body": "CoreDNS resolves: `..svc.cluster.local`. You can verify with: `kubectl exec -- nslookup `.", + "command": "kubectl exec -it -- nslookup db-service.backend.svc.cluster.local" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "storageclass-mcq", + "title": "StorageClass Reclaim Policies", + "category": "Storage", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## StorageClass Reclaim Policies\n\nA PersistentVolumeClaim bound to a PV is deleted. The StorageClass has `reclaimPolicy: Retain`.\n\nWhat happens to the underlying PersistentVolume?", + "options": [ + { + "id": "a", + "text": "The PV is immediately deleted along with all data" + }, + { + "id": "b", + "text": "The PV remains but enters a `Released` state; the data is preserved and must be manually reclaimed" + }, + { + "id": "c", + "text": "The PV is automatically rebound to the next PVC that requests the same storage class" + }, + { + "id": "d", + "text": "The PV is converted to an emptyDir volume attached to the node" + } + ], + "correct_option": "b", + "explanation": "With `reclaimPolicy: Retain`, when a PVC is deleted the PV moves to `Released` state — the data is **preserved** on the storage backend, but the PV is not available for automatic rebinding. An admin must manually reclaim it (delete the PV, clean the storage, recreate the PV). Contrast with `reclaimPolicy: Delete`, which deletes both the PV object and the underlying storage asset automatically.", + "hints": [ + { + "title": "Reclaim policies", + "body": "Three policies exist: `Retain` (manual reclaim), `Delete` (auto-delete storage), `Recycle` (deprecated, basic scrub). Check with: `kubectl get storageclass`.", + "command": "kubectl get storageclass -o jsonpath='{range .items[*]}{.metadata.name}={.reclaimPolicy}{\"\\n\"}{end}'" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "emptydir-pod", + "title": "Shared emptyDir Volume Between Containers", + "category": "Storage", + "difficulty": "Easy", + "type": "task", + "weight": 5, + "description": "## Shared emptyDir Volume\n\nAn `emptyDir` volume is created when a Pod is assigned to a node and exists as long as the Pod runs. It is ideal for sharing data between containers in the same pod (e.g., sidecar patterns).\n\n**Your task:**\n\nCreate a Pod named `shared-data` with two containers:\n1. **writer** (`busybox:1.36`) — writes `hello-kube` to `/shared/message.txt` then sleeps\n2. **reader** (`busybox:1.36`) — reads and prints `/shared/message.txt` then sleeps\n\nBoth containers mount an `emptyDir` volume at `/shared`.", + "hints": [ + { + "title": "emptyDir shared volume manifest", + "body": "Define a single volume of type `emptyDir: {}` and mount it in both containers under the same path.", + "command": "cat < /shared/message.txt && sleep 3600\"]\n volumeMounts:\n - name: shared-vol\n mountPath: /shared\n - name: reader\n image: busybox:1.36\n command: [\"sh\",\"-c\",\"sleep 5 && cat /shared/message.txt && sleep 3600\"]\n volumeMounts:\n - name: shared-vol\n mountPath: /shared\n volumes:\n - name: shared-vol\n emptyDir: {}\nEOF" + } + ], + "setup_commands": [], + "validation": { + "commands": [ + { + "description": "Pod 'shared-data' exists", + "command": "kubectl get pod shared-data -o jsonpath='{.metadata.name}'", + "expected_output": "shared-data", + "match": "exact" + }, + { + "description": "Pod has 2 containers", + "command": "kubectl get pod shared-data -o jsonpath='{range .spec.containers[*]}{.name}{\"\\n\"}{end}'", + "expected_output": "writer", + "match": "contains" + }, + { + "description": "Volume type is emptyDir", + "command": "kubectl get pod shared-data -o jsonpath='{.spec.volumes[0].emptyDir}'", + "expected_output": "{}", + "match": "contains" + }, + { + "description": "Writer container mounts the shared volume", + "command": "kubectl get pod shared-data -o jsonpath='{.spec.containers[0].volumeMounts[0].mountPath}'", + "expected_output": "/shared", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pod shared-data --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "pod-security-context", + "title": "Pod Security Context", + "category": "Security", + "difficulty": "Medium", + "type": "task", + "weight": 6, + "description": "## Pod Security Context\n\nA security context defines privilege and access control settings for a pod or container.\n\n**Your task:**\n\nCreate a Pod named `secure-pod` using `nginx:alpine` with the following security settings:\n- `runAsUser: 1000`\n- `runAsNonRoot: true`\n- `allowPrivilegeEscalation: false`\n\n```bash\n# Verify after creation:\nkubectl exec secure-pod -- id\n```", + "hints": [ + { + "title": "securityContext structure", + "body": "Security context can be set at Pod level (`spec.securityContext`) or container level (`spec.containers[].securityContext`). Container-level settings override pod-level.", + "command": "cat </dev/null || true" + }, + { + "command": "kubectl patch deployment crash-app --type=json -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/command\",\"value\":[\"/bin/sh\",\"-c\",\"exit 1\"]}]'" + } + ], + "validation": { + "commands": [ + { + "description": "Deployment 'crash-app' has 1 ready replica", + "command": "kubectl get deployment crash-app -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -v '^$' || echo 0", + "expected_output": "1", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment crash-app --ignore-not-found" + } + ] + }, + { + "id": "endpoint-fix-task", + "title": "Fix a Broken Service Selector", + "category": "Troubleshooting", + "difficulty": "Medium", + "type": "task", + "weight": 7, + "description": "## Fix a Broken Service Selector\n\nA service `my-svc` has been created but no traffic reaches the pods. The pods are labelled `app=backend`, but the service selector is misconfigured.\n\n**Your task:**\n\nFix the `my-svc` service selector so it correctly targets the `backend` pods.\n\n```bash\n# Investigate:\nkubectl describe service my-svc\nkubectl get endpoints my-svc\nkubectl get pods --show-labels\n```", + "hints": [ + { + "title": "Check endpoints", + "body": "If `kubectl get endpoints my-svc` shows ``, the selector doesn't match any pods. Compare service selector with pod labels.", + "command": "kubectl get endpoints my-svc" + }, + { + "title": "Patch the service selector", + "body": "Use `kubectl patch` to fix the selector.", + "command": "kubectl patch service my-svc --type=json -p='[{\"op\":\"replace\",\"path\":\"/spec/selector/app\",\"value\":\"backend\"}]'" + } + ], + "setup_commands": [ + { + "command": "kubectl create deployment backend-dep --image=nginx:alpine --replicas=2 2>/dev/null || true" + }, + { + "command": "kubectl label deployment backend-dep app=backend --overwrite" + }, + { + "command": "kubectl apply -f - </dev/null | grep -v '^$' || echo empty", + "expected_output": "empty", + "match": "not_contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment backend-dep --ignore-not-found" + }, + { + "command": "kubectl delete svc my-svc --ignore-not-found" + } + ] + }, + { + "id": "ingress-task", + "title": "Create an Ingress Resource", + "category": "Services & Networking", + "difficulty": "Medium", + "type": "task", + "weight": 7, + "description": "## Create an Ingress Resource\n\nIngress exposes HTTP/HTTPS routes from outside the cluster to services within it. k3s includes Traefik as its default Ingress controller.\n\n**Your task:**\n\nA deployment `web-app` and service `web-svc` (port 80) already exist. Create an **Ingress** named `web-ingress` that:\n- Routes traffic for host `app.lab.local`\n- Path `/` → service `web-svc` on port `80`\n- Uses `pathType: Prefix`", + "hints": [ + { + "title": "Ingress manifest", + "body": "Use `networking.k8s.io/v1` for the Ingress API. Define `spec.rules[].host` and `spec.rules[].http.paths[]`.", + "command": "cat </dev/null || true" + }, + { + "command": "kubectl expose deployment web-app --name=web-svc --port=80 --target-port=80 2>/dev/null || true" + } + ], + "validation": { + "commands": [ + { + "description": "Ingress 'web-ingress' exists", + "command": "kubectl get ingress web-ingress -o jsonpath='{.metadata.name}'", + "expected_output": "web-ingress", + "match": "exact" + }, + { + "description": "Ingress host is app.lab.local", + "command": "kubectl get ingress web-ingress -o jsonpath='{.spec.rules[0].host}'", + "expected_output": "app.lab.local", + "match": "exact" + }, + { + "description": "Backend service is web-svc", + "command": "kubectl get ingress web-ingress -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}'", + "expected_output": "web-svc", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment web-app --ignore-not-found" + }, + { + "command": "kubectl delete svc web-svc --ignore-not-found" + }, + { + "command": "kubectl delete ingress web-ingress --ignore-not-found" + } + ] + }, + { + "id": "networkpolicy-egress", + "title": "Egress NetworkPolicy", + "category": "Security", + "difficulty": "Hard", + "type": "task", + "weight": 8, + "description": "## Egress NetworkPolicy\n\nEgress NetworkPolicies control **outbound** traffic from pods. By default, all egress is allowed; once you apply an egress policy to a pod, only explicitly allowed egress is permitted.\n\n**Your task:**\n\nCreate a NetworkPolicy named `restrict-egress` in the `default` namespace that:\n- Applies to pods with label `role=isolated`\n- Allows **egress only to pods with label `role=allowed`** on port `80`\n- Blocks all other egress", + "hints": [ + { + "title": "Egress NetworkPolicy structure", + "body": "Set `spec.policyTypes: [Egress]` and define `spec.egress[]` with `to` and `ports`. Omitting a type means it's not affected.", + "command": "cat </dev/null || true" + } + ], + "validation": { + "commands": [ + { + "description": "LimitRange 'default-limits' exists in bounded-ns", + "command": "kubectl get limitrange default-limits -n bounded-ns -o jsonpath='{.metadata.name}'", + "expected_output": "default-limits", + "match": "exact" + }, + { + "description": "Default CPU limit is 200m", + "command": "kubectl get limitrange default-limits -n bounded-ns -o jsonpath='{.spec.limits[0].default.cpu}'", + "expected_output": "200m", + "match": "exact" + }, + { + "description": "Default memory limit is 256Mi", + "command": "kubectl get limitrange default-limits -n bounded-ns -o jsonpath='{.spec.limits[0].default.memory}'", + "expected_output": "256Mi", + "match": "exact" + } + ] + }, + "default_namespace": "bounded-ns", + "teardown_commands": [ + { + "command": "kubectl delete namespace bounded-ns --ignore-not-found --wait=false" + } + ] + }, + { + "id": "job-task", + "title": "Create a One-Shot Job", + "category": "Workloads & Scheduling", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Create a One-Shot Job\n\nA **Job** creates one or more pods and ensures a specified number of them successfully terminate. Unlike Deployments, Jobs are meant for finite tasks that run to completion.\n\n**Your task:**\n\nCreate a Job named `pi-job` that:\n- Uses `perl:5.34` image\n- Runs: `perl -Mbignum=bpi -wle 'print bpi(100)'` (computes π to 100 digits)\n- `completions: 1`\n- `restartPolicy: Never`", + "hints": [ + { + "title": "Job manifest", + "body": "Set `restartPolicy: Never` or `OnFailure` in the pod template spec (not at pod spec level).", + "command": "cat <` (before worker upgrade), `kubectl uncordon ` (after).", + "command": "kubeadm upgrade plan" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "container-logging-mcq", + "title": "Container Logging and Monitoring", + "category": "Troubleshooting", + "difficulty": "Easy", + "type": "mcq", + "weight": 3, + "description": "## Container Logging and Monitoring\n\nA pod `worker-abc` has crashed and restarted. You want to see the logs from the **previous** (crashed) container instance, not the current one.\n\nWhich command retrieves logs from the previously crashed container?", + "options": [ + { + "id": "a", + "text": "`kubectl logs worker-abc --all-containers`" + }, + { + "id": "b", + "text": "`kubectl logs worker-abc --previous`" + }, + { + "id": "c", + "text": "`kubectl logs worker-abc --restart`" + }, + { + "id": "d", + "text": "`kubectl describe pod worker-abc | grep log`" + } + ], + "correct_option": "b", + "explanation": "`kubectl logs --previous` (or `-p`) retrieves logs from the **terminated previous container** instance in a pod. This is critical for debugging CrashLoopBackOff scenarios where the container restarts before you can inspect its logs. `--all-containers` shows logs from all containers in the current run. `kubectl describe` shows events and status but not stdout/stderr logs.", + "hints": [ + { + "title": "Useful kubectl logs flags", + "body": "`--previous`/`-p`: previous container | `--follow`/`-f`: stream | `--since=1h`: last hour | `--tail=50`: last 50 lines | `--timestamps`: show timestamps", + "command": "kubectl logs worker-abc --previous --tail=50" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "multi-container-pod-basics", + "title": "Create a Multi-Container Pod", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "task", + "weight": 5, + "description": "## Multi-Container Pods\n\nPods can contain multiple containers that share the same network namespace and storage volumes. This is often used for the 'sidecar' pattern.\n\n**Your task:**\n\nCreate a Pod named `web-sidecar` in the `default` namespace with two containers:\n1. Name: `app`, Image: `nginx:alpine`\n2. Name: `sidecar`, Image: `busybox:1.36`, Command: `sleep 3600`\n\nVerify it is running:\n```bash\nkubectl get pod web-sidecar\n```", + "hints": [ + { + "title": "Manifest Structure", + "body": "Your `spec.containers` array needs two items.", + "command": "cat < Tip: Use `kubectl logs log-generator | grep ERROR` to filter.", + "hints": [ + { + "title": "View pod logs", + "body": "Use kubectl logs to stream the output of a running container.", + "command": "kubectl logs log-generator" + }, + { + "title": "Filter logs", + "body": "Pipe kubectl logs into grep to find specific messages.", + "command": "kubectl logs log-generator | grep ERROR" + } + ], + "setup_commands": [ + { + "command": "kubectl run log-generator --image=busybox:1.36 --command -- sh -c 'echo \"ERROR: Database connection failed\" && sleep 3600'" + } + ], + "validation": { + "commands": [ + { + "description": "Pod log-generator emits ERROR: Database connection failed", + "command": "kubectl logs log-generator 2>/dev/null", + "expected_output": "ERROR: Database connection failed", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pod log-generator --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "edit-deployment-basics", + "title": "Updating Deployments", + "category": "Workloads", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Updating a Deployment\n\nA deployment named `web-app` is currently running `nginx:1.24`. A security vulnerability was found in this version.\n\n**Your task:**\n\nUpdate the image of the `web-app` deployment to `nginx:1.25` to patch the vulnerability. Wait for the new pods to become ready.\n\n```bash\n# Verify your change:\nkubectl describe deployment web-app | grep Image\n```", + "hints": [ + { + "title": "Set image command", + "body": "You can use `kubectl set image` to update a deployment's container image immediately.", + "command": "kubectl set image deployment/web-app nginx=nginx:1.25" + } + ], + "setup_commands": [ + { + "command": "kubectl create deployment web-app --image=nginx:1.24" + }, + { + "command": "kubectl rollout status deployment/web-app --timeout=60s" + } + ], + "validation": { + "commands": [ + { + "description": "Deployment uses image nginx:1.25", + "command": "kubectl get deployment web-app -o jsonpath='{.spec.template.spec.containers[0].image}'", + "expected_output": "nginx:1.25", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment web-app --ignore-not-found" + } + ] + }, + { + "id": "jsonpath-basics", + "title": "JSONPath Data Extraction", + "category": "Core Concepts", + "difficulty": "Medium", + "type": "task", + "weight": 5, + "description": "## Extracting Data with JSONPath\n\nKubectl allows you to extract specific fields from resource manifests using JSONPath, which is incredibly useful for scripting and automation.\n\n**Your task:**\n\nA deployment named `hidden-app` is running in the `default` namespace. Use `kubectl` with a JSONPath expression to output **only the container image name** used by that deployment.\n\n> Expected output: `nginx:1.25.3`", + "hints": [ + { + "title": "JSONPath syntax", + "body": "The image is located at `.spec.template.spec.containers[0].image`. Use -o jsonpath to extract it.", + "command": "kubectl get deployment hidden-app -o jsonpath='{.spec.template.spec.containers[0].image}'" + } + ], + "setup_commands": [ + { + "command": "kubectl create deployment hidden-app --image=nginx:1.25.3" + } + ], + "validation": { + "commands": [ + { + "description": "kubectl jsonpath returns the correct image", + "command": "kubectl get deployment hidden-app -o jsonpath='{.spec.template.spec.containers[0].image}'", + "expected_output": "nginx:1.25.3", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete deployment hidden-app --ignore-not-found" + } + ] + }, + { + "id": "dry-run-manifest-basics", + "title": "Generating YAML Manifests", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Generating and Applying Manifests with Dry Run\n\nInstead of writing YAML from scratch, you can use `kubectl` to generate templates using `--dry-run=client -o yaml` and then apply them.\n\n**Your task:**\n\nUse `kubectl run` with `--dry-run=client -o yaml` to generate a Pod manifest for `web-pod` using image `httpd:alpine`, then pipe it directly to `kubectl apply -f -` to create the pod.\n\n```bash\nkubectl run web-pod --image=httpd:alpine --dry-run=client -o yaml | kubectl apply -f -\n```", + "hints": [ + { + "title": "Combine dry-run with apply", + "body": "Chain kubectl run --dry-run=client -o yaml with kubectl apply -f - using a pipe.", + "command": "kubectl run web-pod --image=httpd:alpine --dry-run=client -o yaml | kubectl apply -f -" + } + ], + "setup_commands": [], + "validation": { + "commands": [ + { + "description": "Pod 'web-pod' exists", + "command": "kubectl get pod web-pod -o jsonpath='{.metadata.name}'", + "expected_output": "web-pod", + "match": "exact" + }, + { + "description": "Pod uses httpd:alpine image", + "command": "kubectl get pod web-pod -o jsonpath='{.spec.containers[0].image}'", + "expected_output": "httpd:alpine", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pod web-pod --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "delete-by-label-basics", + "title": "Bulk Deletion by Label", + "category": "Core Concepts", + "difficulty": "Easy", + "type": "task", + "weight": 3, + "description": "## Bulk Deletion by Label\n\nYou can use label selectors (`-l`) to perform bulk operations on resources.\n\n**Your task:**\n\nThere are 5 pods running in the default namespace. Delete **only** the pods that have the label `env=dev`. Leave the `env=prod` pods running.\n\n```bash\n# Verify which pods are left:\nkubectl get pods --show-labels\n```", + "hints": [ + { + "title": "Delete with selector", + "body": "Use `kubectl delete pods` with the `-l` flag.", + "command": "kubectl delete pods -l env=dev" + } + ], + "setup_commands": [ + { + "command": "kubectl run dev-1 --image=nginx:alpine -l env=dev" + }, + { + "command": "kubectl run dev-2 --image=nginx:alpine -l env=dev" + }, + { + "command": "kubectl run dev-3 --image=nginx:alpine -l env=dev" + }, + { + "command": "kubectl run prod-1 --image=nginx:alpine -l env=prod" + }, + { + "command": "kubectl run prod-2 --image=nginx:alpine -l env=prod" + } + ], + "validation": { + "commands": [ + { + "description": "No 'env=dev' pods exist", + "command": "kubectl get pods -l env=dev --no-headers 2>/dev/null | wc -l | tr -d ' '", + "expected_output": "0", + "match": "exact" + }, + { + "description": "Prod pods are still running", + "command": "kubectl get pods -l env=prod --no-headers 2>/dev/null | wc -l | tr -d ' '", + "expected_output": "2", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pods -l env=dev --ignore-not-found --grace-period=0 --force" + }, + { + "command": "kubectl delete pods -l env=prod --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "kubectl-cp-basics", + "title": "Copy Files to Containers", + "category": "Troubleshooting", + "difficulty": "Medium", + "type": "task", + "weight": 4, + "description": "## Copy Files Between Containers and the Cluster\n\nThe `kubectl cp` command allows you to copy files and directories to and from running containers.\n\n**Your task:**\n\nA pod named `config-pod` is running with a file at `/src/config.json`. Copy it **into** the same pod at `/app/config.json` using `kubectl cp`.\n\n> Tip: `kubectl cp` syntax is `kubectl cp :`", + "hints": [ + { + "title": "Use kubectl cp", + "body": "First copy the file out from /src, then copy it back to /app, or use kubectl exec to verify.", + "command": "kubectl cp config-pod:/src/config.json /tmp/config.json && kubectl cp /tmp/config.json config-pod:/app/config.json" + } + ], + "setup_commands": [ + { + "command": "kubectl run config-pod --image=busybox:1.36 --command -- sleep 3600" + }, + { + "command": "kubectl wait --for=condition=Ready pod/config-pod --timeout=60s" + }, + { + "command": "kubectl exec config-pod -- sh -c 'mkdir -p /src && echo \\'{ \"status\": \"ok\" }\\' > /src/config.json'" + }, + { + "command": "kubectl exec config-pod -- mkdir -p /app" + } + ], + "validation": { + "commands": [ + { + "description": "File /app/config.json exists inside pod", + "command": "kubectl exec config-pod -- cat /app/config.json 2>/dev/null", + "expected_output": "{\"status\": \"ok\"}", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pod config-pod --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "exec-command-basics", + "title": "Execute Commands in Pods", + "category": "Troubleshooting", + "difficulty": "Easy", + "type": "task", + "weight": 3, + "description": "## Execute Commands in Pods\n\nSometimes you need to run arbitrary commands inside a running container to debug or change state.\n\n**Your task:**\n\nA pod named `worker-pod` is running. Use `kubectl exec` to create an empty file at `/tmp/ready` inside the container.\n\n```bash\n# Verify the file was created:\nkubectl exec worker-pod -- ls /tmp/ready\n```", + "hints": [ + { + "title": "Use kubectl exec", + "body": "You can pass commands to the container by adding `--` after the pod name.", + "command": "kubectl exec worker-pod -- touch /tmp/ready" + } + ], + "setup_commands": [ + { + "command": "kubectl run worker-pod --image=busybox:1.36 --command -- sleep 3600" + }, + { + "command": "kubectl wait --for=condition=Ready pod/worker-pod --timeout=30s" + } + ], + "validation": { + "commands": [ + { + "description": "File /tmp/ready exists inside worker-pod", + "command": "kubectl exec worker-pod -- ls /tmp/ready 2>/dev/null", + "expected_output": "/tmp/ready", + "match": "contains" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete pod worker-pod --ignore-not-found --grace-period=0 --force" + } + ] + }, + { + "id": "cks-network-policy", + "title": "Default Deny Network Policy", + "category": "Network Security", + "difficulty": "Medium", + "type": "task", + "weight": 5, + "description": "## Network Policies\n\nIn a zero-trust architecture, you should deny all traffic by default and explicitly allow what is needed.\n\n**Your task:**\n\nCreate a NetworkPolicy named `default-deny-all` in the `default` namespace that denies all ingress and egress traffic for all pods in the namespace.\n\n```bash\n# Verify your policy:\nkubectl get networkpolicy default-deny-all\n```", + "hints": [ + { + "title": "Default Deny YAML", + "body": "Use a podSelector with an empty matchLabels `{}` to select all pods, and provide empty lists for ingress and egress.", + "command": "cat </dev/null", + "expected_output": "169.254.169.254/32", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete networkpolicy deny-metadata --ignore-not-found" + } + ] + }, + { + "id": "cks-rbac-clusterrole", + "title": "Cluster-Level RBAC", + "category": "Cluster Security", + "difficulty": "Medium", + "type": "task", + "weight": 5, + "description": "## Cluster Roles and Bindings\n\nSome resources, like Nodes, are cluster-scoped and cannot be accessed using normal Roles and RoleBindings.\n\n**Your task:**\n\n1. Create a ServiceAccount named `monitor-sa` in the `monitoring` namespace.\n2. Create a ClusterRole named `node-viewer` that grants `get`, `list`, and `watch` permissions on `nodes`.\n3. Create a ClusterRoleBinding named `monitor-node-binding` to bind the ClusterRole to the ServiceAccount.\n\n```bash\n# Verify your permissions:\nkubectl auth can-i list nodes --as=system:serviceaccount:monitoring:monitor-sa\n```", + "hints": [ + { + "title": "Imperative Commands", + "body": "Create the SA, then the ClusterRole, then the ClusterRoleBinding.", + "command": "kubectl create sa monitor-sa -n monitoring && kubectl create clusterrole node-viewer --verb=get,list,watch --resource=nodes && kubectl create clusterrolebinding monitor-node-binding --clusterrole=node-viewer --serviceaccount=monitoring:monitor-sa" + } + ], + "setup_commands": [ + { + "command": "kubectl create namespace monitoring --dry-run=client -o yaml | kubectl apply -f -" + } + ], + "validation": { + "commands": [ + { + "description": "monitor-sa exists in monitoring namespace", + "command": "kubectl get sa monitor-sa -n monitoring -o jsonpath='{.metadata.name}'", + "expected_output": "monitor-sa", + "match": "exact" + }, + { + "description": "ServiceAccount can list nodes", + "command": "kubectl auth can-i list nodes --as=system:serviceaccount:monitoring:monitor-sa", + "expected_output": "yes", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete sa monitor-sa -n monitoring --ignore-not-found" + }, + { + "command": "kubectl delete clusterrole node-viewer --ignore-not-found" + }, + { + "command": "kubectl delete clusterrolebinding monitor-node-binding --ignore-not-found" + } + ] + }, + { + "id": "cks-psa-namespace", + "title": "Pod Security Admission", + "category": "Cluster Security", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Pod Security Admission (PSA)\n\nPod Security Admission replaced PodSecurityPolicies (PSP) to enforce security standards at the namespace level.\n\n**Your task:**\n\nA namespace named `secure-workloads` already exists. Add the necessary label to this namespace to **enforce** the `restricted` pod security standard.\n\n```bash\n# Verify your label:\nkubectl get ns secure-workloads --show-labels\n```", + "hints": [ + { + "title": "Namespace Label", + "body": "Label the namespace with the `pod-security.kubernetes.io/enforce=restricted` key-value pair.", + "command": "kubectl label ns secure-workloads pod-security.kubernetes.io/enforce=restricted" + } + ], + "setup_commands": [ + { + "command": "kubectl create namespace secure-workloads --dry-run=client -o yaml | kubectl apply -f -" + } + ], + "validation": { + "commands": [ + { + "description": "Namespace secure-workloads is labeled", + "command": "kubectl get ns secure-workloads -o jsonpath='{.metadata.labels.pod-security\\.kubernetes\\.io/enforce}'", + "expected_output": "restricted", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete namespace secure-workloads --ignore-not-found --wait=false" + } + ] + }, + { + "id": "cks-mcq-runtime-security", + "title": "Runtime Security Tools", + "category": "System Hardening", + "difficulty": "Medium", + "type": "mcq", + "weight": 2, + "description": "Which of the following is an open-source tool specifically designed for **runtime security** in Kubernetes (e.g., detecting unexpected behavior or shell execution in running containers)?", + "options": [ + { + "id": "a", + "text": "Trivy" + }, + { + "id": "b", + "text": "Kube-bench" + }, + { + "id": "c", + "text": "Falco" + }, + { + "id": "d", + "text": "SonarQube" + } + ], + "correct_option": "c", + "explanation": "Falco is a CNCF incubating project that acts as a runtime security tool. It parses Linux system calls at runtime and asserts the stream against a powerful rules engine. Trivy is for image scanning, and kube-bench checks CIS benchmarks.", + "hints": [], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "cks-mcq-api-server", + "title": "API Server Authentication", + "category": "Cluster Setup", + "difficulty": "Easy", + "type": "mcq", + "weight": 2, + "description": "To secure the `kube-apiserver`, you should prevent unauthorized users from interacting with the cluster anonymously. Which flag is used to disable anonymous authentication on the API server?", + "options": [ + { + "id": "a", + "text": "--disable-anonymous=true" + }, + { + "id": "b", + "text": "--anonymous-auth=false" + }, + { + "id": "c", + "text": "--auth-mode=Node,RBAC" + }, + { + "id": "d", + "text": "--secure-port=6443" + } + ], + "correct_option": "b", + "explanation": "The `--anonymous-auth=false` flag instructs the kube-apiserver to reject any request that is not associated with a known user or service account. By default, it is enabled.", + "hints": [], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "cks-mcq-image-footprint", + "title": "Minimizing Image Footprint", + "category": "Supply Chain Security", + "difficulty": "Easy", + "type": "mcq", + "weight": 2, + "description": "Why is it highly recommended in the CKS exam to use base images like **Alpine Linux** or **Distroless** for your containerized applications?", + "options": [ + { + "id": "a", + "text": "They automatically encrypt data at rest" + }, + { + "id": "b", + "text": "They have a significantly reduced attack surface with fewer packages" + }, + { + "id": "c", + "text": "They automatically configure NetworkPolicies for the pod" + }, + { + "id": "d", + "text": "They guarantee that the container will not run as root" + } + ], + "correct_option": "b", + "explanation": "Minimal images like Alpine or Distroless contain only the bare minimum files and dependencies needed to run the application. This drastically reduces the attack surface and the number of potential CVEs.", + "hints": [], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "cks-mcq-psp-replacement", + "title": "Pod Security Evolution", + "category": "Cluster Security", + "difficulty": "Easy", + "type": "mcq", + "weight": 2, + "description": "PodSecurityPolicies (PSP) were completely removed from Kubernetes in version 1.25. What is the built-in native replacement that enforces Pod Security Standards at the namespace level?", + "options": [ + { + "id": "a", + "text": "NetworkPolicies" + }, + { + "id": "b", + "text": "OPA Gatekeeper" + }, + { + "id": "c", + "text": "Pod Security Admission (PSA)" + }, + { + "id": "d", + "text": "AppArmor Profiles" + } + ], + "correct_option": "c", + "explanation": "Pod Security Admission (PSA) is the built-in admission controller that evaluates Pods against the predefined Pod Security Standards (Privileged, Baseline, and Restricted) based on namespace labels.", + "hints": [], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "cks-mcq-kubelet-auth", + "title": "Securing the Kubelet", + "category": "Cluster Setup", + "difficulty": "Medium", + "type": "mcq", + "weight": 2, + "description": "The kubelet exposes its own API on port 10250, which can allow an attacker to run `exec` commands on pods if left unsecured. Which configuration setting in the kubelet config file ensures unauthenticated requests are rejected?", + "options": [ + { + "id": "a", + "text": "authorization.mode: Webhook" + }, + { + "id": "b", + "text": "readOnlyPort: 0" + }, + { + "id": "c", + "text": "authentication.anonymous.enabled: false" + }, + { + "id": "d", + "text": "protectKernelDefaults: true" + } + ], + "correct_option": "c", + "explanation": "Setting `authentication.anonymous.enabled: false` ensures that the kubelet will reject any API requests that do not present a valid client certificate or bearer token.", + "hints": [], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "cks-readonly-filesystem", + "title": "Read-Only Root Filesystem", + "category": "Workload Security", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Read-Only Root Filesystem\n\nSetting `readOnlyRootFilesystem: true` prevents any process inside the container from writing to the container's root filesystem layer, significantly reducing the blast radius of a compromised container.\n\n**Your task:**\n\nCreate a Pod named `readonly-pod` using `busybox:1.36` (command: `sleep 3600`) with:\n- `readOnlyRootFilesystem: true` in the container's `securityContext`\n- An `emptyDir` volume mounted at `/tmp` to provide writable scratch space\n\n```bash\n# Verify:\nkubectl get pod readonly-pod -o jsonpath='{.spec.containers[0].securityContext.readOnlyRootFilesystem}'\n```", + "hints": [ + { + "title": "readOnlyRootFilesystem with emptyDir", + "body": "Set `readOnlyRootFilesystem: true` in the container's `securityContext`. Mount an `emptyDir` volume at `/tmp` so the process has a writable location if needed.", + "command": "cat </dev/null" + }, + { + "command": "kubectl create deployment tls-app --image=nginx:alpine --replicas=1 2>/dev/null || true" + }, + { + "command": "kubectl expose deployment tls-app --name=tls-svc --port=80 --target-port=80 2>/dev/null || true" + } + ], + "validation": { + "commands": [ + { + "description": "Secret 'app-tls' is of type kubernetes.io/tls", + "command": "kubectl get secret app-tls -o jsonpath='{.type}'", + "expected_output": "kubernetes.io/tls", + "match": "exact" + }, + { + "description": "Ingress 'tls-ingress' exists", + "command": "kubectl get ingress tls-ingress -o jsonpath='{.metadata.name}'", + "expected_output": "tls-ingress", + "match": "exact" + }, + { + "description": "Ingress uses TLS secret 'app-tls'", + "command": "kubectl get ingress tls-ingress -o jsonpath='{.spec.tls[0].secretName}'", + "expected_output": "app-tls", + "match": "exact" + }, + { + "description": "Ingress routes host secure.lab.local", + "command": "kubectl get ingress tls-ingress -o jsonpath='{.spec.rules[0].host}'", + "expected_output": "secure.lab.local", + "match": "exact" + } + ] + }, + "default_namespace": "default", + "teardown_commands": [ + { + "command": "kubectl delete ingress tls-ingress --ignore-not-found" + }, + { + "command": "kubectl delete secret app-tls --ignore-not-found" + }, + { + "command": "kubectl delete svc tls-svc --ignore-not-found" + }, + { + "command": "kubectl delete deployment tls-app --ignore-not-found" + }, + { + "command": "rm -f /tmp/tls.crt /tmp/tls.key" + } + ] + }, + { + "id": "cks-image-pull-secret", + "title": "Private Registry Image Pull Secret", + "category": "Supply Chain Security", + "difficulty": "Easy", + "type": "task", + "weight": 4, + "description": "## Private Registry Image Pull Secret\n\nIn production, images are stored in private registries that require authentication. Kubernetes uses `imagePullSecrets` to securely store registry credentials and inject them at image pull time.\n\n**Your task:**\n\n1. Create a Docker registry Secret named `registry-creds` for registry `registry.company.com` with:\n - Username: `ci-bot`\n - Password: `s3cr3t-token`\n - Email: `ci@company.com`\n2. Create a Pod named `private-pod` using `nginx:alpine` that references `registry-creds` as an `imagePullSecret`\n\n```bash\n# Verify:\nkubectl get pod private-pod -o jsonpath='{.spec.imagePullSecrets[0].name}'\n```", + "hints": [ + { + "title": "Create a docker-registry Secret", + "body": "Use `kubectl create secret docker-registry` with the four required flags.", + "command": "kubectl create secret docker-registry registry-creds \\\n --docker-server=registry.company.com \\\n --docker-username=ci-bot \\\n --docker-password=s3cr3t-token \\\n --docker-email=ci@company.com" + }, + { + "title": "Reference imagePullSecrets in a Pod", + "body": "Add `spec.imagePullSecrets[].name` to the pod spec with the name of your secret.", + "command": "cat <`.", + "command": "kubectl explain runtimeclass\nkubectl explain pod.spec.runtimeClassName" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + }, + { + "id": "cks-mcq-audit-policy", + "title": "Kubernetes Audit Logging Levels", + "category": "Cluster Setup", + "difficulty": "Medium", + "type": "mcq", + "weight": 3, + "description": "## Kubernetes Audit Logging\n\nYou are writing an audit policy for the kube-apiserver. You need to capture the **complete request AND response body** for all write operations on `secrets` in any namespace, so you have a full record of every secret modification.\n\nWhich audit level must you specify for these events?", + "options": [ + { + "id": "a", + "text": "`None` — discard all events matching this rule; nothing is logged" + }, + { + "id": "b", + "text": "`Metadata` — logs request metadata (user, verb, resource, timestamp) but not the request or response body" + }, + { + "id": "c", + "text": "`Request` — logs metadata and the request body, but not the response body" + }, + { + "id": "d", + "text": "`RequestResponse` — logs metadata, the full request body, and the full response body" + } + ], + "correct_option": "d", + "explanation": "Kubernetes audit levels in ascending verbosity: **None → Metadata → Request → RequestResponse**. `RequestResponse` is required when you need the complete picture — what was sent and what the API server returned. This is most important for sensitive resources like Secrets. The audit policy file is passed to kube-apiserver via `--audit-policy-file`. Note that `RequestResponse` is expensive; use `Metadata` for most resources.", + "hints": [ + { + "title": "Audit policy levels", + "body": "The four levels: `None` (discard), `Metadata` (headers only), `Request` (+ request body), `RequestResponse` (+ response body). Configure via `--audit-policy-file` and `--audit-log-path` on the kube-apiserver.", + "command": "# Example rule targeting Secrets at RequestResponse level:\n# - level: RequestResponse\n# resources:\n# - group: \"\"\n# resources: [\"secrets\"]" + } + ], + "setup_commands": [], + "default_namespace": "default", + "teardown_commands": [] + } +] \ No newline at end of file diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..d19feca --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,186 @@ +#!/bin/bash +set -e + +LOG() { echo -e "\033[36m[k8s-lab]\033[0m $*"; } +OK() { echo -e "\033[32m[k8s-lab]\033[0m ✓ $*"; } +ERR() { echo -e "\033[31m[k8s-lab]\033[0m ✗ $*" >&2; } + +LOG "Starting KubeKosh..." + +# ── 0. Fix cgroupv2 hierarchy (Docker Desktop / Mac) ───────────────────────── +# cgroupv2 enforces the "no-internal-process constraint": a cgroup with domain +# controllers (cpu, memory, etc.) cannot have processes AND child cgroups at the +# same level. Docker Desktop places our container's processes in the root cgroup, +# making it impossible for containerd/runc to create pod sub-cgroups (k8s.io). +# +# Fix (same as k3d): move all current processes to a leaf cgroup first, then +# enable all available controllers in the root's subtree_control. +if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + LOG "Configuring cgroupv2 delegation..." + mkdir -p /sys/fs/cgroup/init + # Move every process currently in the root cgroup into the leaf + xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || true + # Enable all available controllers for child cgroups (e.g. k8s.io, kubepods) + sed -e 's/ / +/g' -e 's/^/+/' \ + < /sys/fs/cgroup/cgroup.controllers \ + > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true + OK "cgroupv2 delegation configured" +fi + +# ── 1. Start k3s server ────────────────────────────────────────────────────── +LOG "Starting k3s (Kubernetes)..." + +# k3s needs cgroupv2 or cgroupv1 mounted; --disable flags slim it down for lab use +k3s server \ + --disable=traefik \ + --disable=servicelb \ + --write-kubeconfig-mode=644 \ + --node-name=k8s-lab \ + --snapshotter=native \ + --kubelet-arg=cgroups-per-qos=false \ + --kubelet-arg=enforce-node-allocatable="" \ + &>/var/log/k3s.log & +K3S_PID=$! + +# Wait for k3s API server to be ready +KUBECONFIG_PATH=/etc/rancher/k3s/k3s.yaml +for i in $(seq 1 60); do + if [ -f "$KUBECONFIG_PATH" ] && \ + kubectl --kubeconfig="$KUBECONFIG_PATH" get nodes &>/dev/null 2>&1; then + break + fi + sleep 2 + if [ $i -eq 60 ]; then + ERR "k3s failed to start. Last log lines:" + tail -20 /var/log/k3s.log >&2 + exit 1 + fi +done +OK "k3s API server is up" + +# Symlink kubeconfig to the standard location for convenience +mkdir -p /root/.kube +cp "$KUBECONFIG_PATH" /root/.kube/config +export KUBECONFIG=/root/.kube/config + +# ── 2. Wait for node to be Ready ──────────────────────────────────────────── +LOG "Waiting for cluster node to become Ready..." + +# Phase 1: wait until at least one node is registered +# (kubectl wait --all exits immediately with error if no resources exist yet) +# Stream k3s logs to stdout in background so failures are visible +tail -f /var/log/k3s.log & +TAIL_PID=$! + +for i in $(seq 1 90); do + NODE_COUNT=$(kubectl get nodes --no-headers 2>/dev/null | wc -l) + if [ "$NODE_COUNT" -gt 0 ]; then + kill $TAIL_PID 2>/dev/null || true + break + fi + sleep 3 + if [ $i -eq 90 ]; then + kill $TAIL_PID 2>/dev/null || true + ERR "Timed out waiting for a node to register (270s)" + ERR "k3s node status:" + kubectl get nodes 2>&1 >&2 || true + exit 1 + fi +done + +# Phase 2: wait for the node to reach Ready condition +kubectl wait --for=condition=Ready nodes --all --timeout=120s +OK "Cluster node is Ready" + +# Phase 3: wait for flannel CNI to write its subnet config. +# Pods scheduled before flannel is ready get FailedCreatePodSandBox warnings +# (missing /run/flannel/subnet.env). Waiting here avoids that noise. +for i in $(seq 1 30); do + [ -f /run/flannel/subnet.env ] && break + sleep 1 +done + + +# ── 3. Install metrics-server ──────────────────────────────────────────────── +# LOG "Installing metrics-server..." +# kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml &>/dev/null || true +# kubectl patch deployment metrics-server -n kube-system \ +# --type='json' \ +# -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' \ +# &>/dev/null 2>&1 || true +# OK "Metrics-server applied" + +# ── 4. Shell environment ───────────────────────────────────────────────────── +LOG "Configuring shell environment..." + +cat >> /root/.bashrc << 'BASHRC' + +# KubeKosh aliases +export KUBECONFIG=/root/.kube/config +alias k='kubectl' +alias kgp='kubectl get pods' +alias kga='kubectl get pods --all-namespaces' +alias kgd='kubectl get deployments' +alias kgs='kubectl get services' +alias kgn='kubectl get nodes' +alias kgns='kubectl get namespaces' +alias kdp='kubectl describe pod' +alias kaf='kubectl apply -f' +alias kdf='kubectl delete -f' +alias kg='kubectl get' +alias kd='kubectl describe' +alias krm='kubectl delete' +alias kex='kubectl exec -it' +alias klogs='kubectl logs' + +# Useful functions +kns() { kubectl config set-context --current --namespace="$1"; } +kctx() { kubectl config use-context "$1"; } + +source <(kubectl completion bash) 2>/dev/null || true +complete -F __start_kubectl k 2>/dev/null || true + +PS1='\[\033[01;32m\]\u@k8s-lab\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' + +echo "" +echo " ⎈ KubeKosh - Node: k8s-lab" +KUBECTL_VER=$(kubectl version --client 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+[^ ]*' | head -1) +echo " kubectl ${KUBECTL_VER}" +echo " Aliases: k=kubectl, kgp=get pods, kaf=apply -f, kns=set-namespace, kgns=get namespaces, kex=kubectl exec -it" +echo " kgd=get deployments, kgn=get nodes, kgs=get services, kdp=describe pod, krm=kubectl delete, klogs=kubectl logs" +echo "" +BASHRC + +OK "Shell configured" + +# ── 5. Start Node.js API server ────────────────────────────────────────────── +LOG "Starting API server..." +cd /app/backend && node server.js &>/var/log/api.log & +OK "API server started (port 4000)" + +# ── 6. Browser terminal ────────────────────────────────────────────────────── +# Terminal is served via WebSocket at /shell-ws by the Node.js API server +# using node-pty — no external ttyd binary needed. + + +# ── 7. Start nginx reverse proxy ──────────────────────────────────────────── +LOG "Starting nginx proxy..." +nginx -g 'daemon off;' &>/var/log/nginx.log & +OK "nginx started (port 80)" + +# ── 8. Keep Alive & Graceful Shutdown ──────────────────────────────────────── +cleanup() { + LOG "Caught signal, shutting down KubeKosh..." + kill -TERM "$K3S_PID" 2>/dev/null || true + kill $(jobs -p) 2>/dev/null || true + exit 0 +} + +trap cleanup SIGINT SIGTERM + +LOG "══════════════════════════════════════════════════" +LOG " KubeKosh is ready! → http://localhost:7554 " +LOG "══════════════════════════════════════════════════" + +# Wait for background jobs. When a signal is caught, wait returns instantly and triggers cleanup. +wait diff --git a/scripts/nginx.conf b/scripts/nginx.conf new file mode 100644 index 0000000..e009d08 --- /dev/null +++ b/scripts/nginx.conf @@ -0,0 +1,49 @@ +worker_processes 1; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + # Upstream: Node.js API + static frontend + WebSocket PTY + upstream api { + server 127.0.0.1:4000; + } + + server { + listen 80; + server_name _; + + # WebSocket terminal — /shell-ws is handled by the Node.js backend + location /shell-ws { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 7d; + } + + # API routes + location /api/ { + proxy_pass http://api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Everything else → React SPA (served by same Node.js process) + location / { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + } +}