feat(curriculum): add RootNode learning path, student roster, and scenario isolation metadata
Pre-Phase-1 platform work, approved and tested earlier but not yet committed: - Terminal banner: "RootNode Academy Lab — node: k8s-lab", upstream KubeKosh attribution left untouched in footer/README/LICENSE. - RootNode curriculum mode: scenarios/rootnode-path.json (Foundation -> Workloads -> Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone), built from existing scenario content grouped by category, no new scenarios invented. New GET /api/curriculum/path endpoint. - Student roster + per-student progress: instructor-roster, pick-your-name login (no password), students/environments-adjacent progress table migrated to a (student_id, scenario_id) composite key so practice progress and exam sessions are isolated per student instead of one global bucket. New /api/roster, /api/login endpoints. - scenarios/data/*.json: added isolation_type metadata (shared_namespace vs ephemeral_cluster) from an audit of which scenarios execute cluster-scoped or node-level commands. This was Tier-1-isolation groundwork that got superseded same-day by the full-ephemeral-per-student architecture (see docs/LAB_ARCHITECTURE.md) — the field is kept as descriptive metadata only, per the note now in scenarios/SCHEMA.md. Not wasted: it's an accurate audit of which scenarios touch cluster-scoped resources, useful independent of isolation model. - .gitignore added (node_modules/, frontend/dist/, *.db, /data/, .env) — none existed before; without it, npm installs during testing would have landed in a commit. Known limitation still open: no frontend UI for any of this yet (login screen, roster-add form, curriculum view) — API-only. No auth on the roster-add endpoint. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
/data/
|
||||
.env
|
||||
Generated
+1184
File diff suppressed because it is too large
Load Diff
+211
-43
@@ -14,6 +14,7 @@ app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
const SCENARIOS_DIR = path.join(__dirname, '../scenarios/data');
|
||||
const BUNDLES_DIR = path.join(__dirname, '../scenarios/bundles');
|
||||
const CURRICULUM_PATH_FILE = path.join(__dirname, '../scenarios/rootnode-path.json');
|
||||
const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db';
|
||||
|
||||
// ── SQLite progress store ─────────────────────────────────────────────────────
|
||||
@@ -55,6 +56,51 @@ function getDb() {
|
||||
console.error('Failed to run schema migrations on progress table:', e.message);
|
||||
}
|
||||
|
||||
// ── Students / roster ────────────────────────────────────────────────────
|
||||
// ponytail: pick-your-name login, no password — instructor adds students to the
|
||||
// roster, student just selects their name. Trust model is "supervised classroom
|
||||
// device", same as Clever/ClassLink instant login. Upgrade to per-student PIN
|
||||
// if remote/unsupervised access is ever needed.
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
student_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
cohort TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_active_at TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate progress table to a per-student composite key (student_id, scenario_id).
|
||||
// SQLite can't ALTER a primary key, so this rebuilds the table. Rows that predate
|
||||
// student accounts are kept under student_id='unassigned' rather than dropped.
|
||||
try {
|
||||
const progressCols = _db.prepare("PRAGMA table_info(progress)").all().map(c => c.name);
|
||||
if (!progressCols.includes('student_id')) {
|
||||
console.log('Migrating progress table to per-student schema...');
|
||||
_db.exec(`
|
||||
CREATE TABLE progress_new (
|
||||
student_id TEXT NOT NULL DEFAULT 'unassigned',
|
||||
scenario_id TEXT NOT NULL,
|
||||
status TEXT,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_validated TEXT,
|
||||
completed_at TEXT,
|
||||
started_at TEXT,
|
||||
notes TEXT,
|
||||
time_spent_seconds INTEGER,
|
||||
PRIMARY KEY (student_id, scenario_id)
|
||||
);
|
||||
INSERT INTO progress_new (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
SELECT 'unassigned', scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds FROM progress;
|
||||
DROP TABLE progress;
|
||||
ALTER TABLE progress_new RENAME TO progress;
|
||||
`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate progress table to per-student schema:', e.message);
|
||||
}
|
||||
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -75,6 +121,9 @@ function getDb() {
|
||||
if (!cols.includes('scenario_ids')) {
|
||||
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`);
|
||||
}
|
||||
if (!cols.includes('student_id')) {
|
||||
_db.exec(`ALTER TABLE sessions ADD COLUMN student_id TEXT`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate sessions table:', e.message);
|
||||
}
|
||||
@@ -94,9 +143,26 @@ function getDb() {
|
||||
return _db;
|
||||
}
|
||||
|
||||
function loadProgress() {
|
||||
// Resolves the acting student for a request. No session tokens yet (see students
|
||||
// table comment) — frontend just sends back the student_id it got from /api/login.
|
||||
// Falls back to 'unassigned' so the app still works before any student logs in
|
||||
// (dev/test use, and pre-migration progress lives there too).
|
||||
function getStudentId(req) {
|
||||
return req.get('x-student-id') || req.query.studentId || 'unassigned';
|
||||
}
|
||||
|
||||
function touchStudent(studentId) {
|
||||
if (!studentId || studentId === 'unassigned') return;
|
||||
try {
|
||||
const rows = getDb().prepare('SELECT * FROM progress').all();
|
||||
getDb().prepare(`UPDATE students SET last_active_at = datetime('now') WHERE student_id = ?`).run(studentId);
|
||||
} catch (e) {
|
||||
console.error('Failed to update student last_active_at:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadProgress(studentId = 'unassigned') {
|
||||
try {
|
||||
const rows = getDb().prepare('SELECT * FROM progress WHERE student_id = ?').all(studentId);
|
||||
return Object.fromEntries(rows.map(r => [r.scenario_id, {
|
||||
status: r.status,
|
||||
attempts: r.attempts,
|
||||
@@ -109,13 +175,13 @@ function loadProgress() {
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveProgress(progress) {
|
||||
function saveProgress(studentId, progress) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scenario_id) DO UPDATE SET
|
||||
INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(student_id, scenario_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
attempts = excluded.attempts,
|
||||
last_validated = excluded.last_validated,
|
||||
@@ -127,6 +193,7 @@ function saveProgress(progress) {
|
||||
const tx = db.transaction((entries) => {
|
||||
for (const [id, p] of entries) {
|
||||
upsert.run(
|
||||
studentId,
|
||||
id,
|
||||
p.status || null,
|
||||
p.attempts || 0,
|
||||
@@ -153,11 +220,15 @@ function loadJsonDir(dir) {
|
||||
|
||||
let scenariosCache = [];
|
||||
let bundlesCache = [];
|
||||
let curriculumPathCache = null;
|
||||
|
||||
function reloadCache() {
|
||||
try {
|
||||
scenariosCache = loadJsonDir(SCENARIOS_DIR);
|
||||
bundlesCache = loadJsonDir(BUNDLES_DIR);
|
||||
curriculumPathCache = fs.existsSync(CURRICULUM_PATH_FILE)
|
||||
? JSON.parse(fs.readFileSync(CURRICULUM_PATH_FILE, 'utf8'))
|
||||
: null;
|
||||
console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload cache:', e.message);
|
||||
@@ -175,6 +246,10 @@ function loadBundles() {
|
||||
return bundlesCache;
|
||||
}
|
||||
|
||||
function loadCurriculumPath() {
|
||||
return curriculumPathCache;
|
||||
}
|
||||
|
||||
async function runCommand(cmd, timeoutMs = 15000) {
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
@@ -223,31 +298,77 @@ function refreshPrompt(delayMs = 80) {
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Roster / student login ──────────────────────────────────────────────────
|
||||
|
||||
// POST /api/roster — instructor adds a student to a cohort
|
||||
app.post('/api/roster', (req, res) => {
|
||||
const { display_name, cohort } = req.body;
|
||||
if (!display_name || !cohort) {
|
||||
return res.status(400).json({ error: 'display_name and cohort are required' });
|
||||
}
|
||||
try {
|
||||
const db = getDb();
|
||||
const count = db.prepare('SELECT COUNT(*) AS n FROM students').get().n;
|
||||
const studentId = `student-${String(count + 1).padStart(3, '0')}`;
|
||||
db.prepare(`INSERT INTO students (student_id, display_name, cohort, created_at) VALUES (?, ?, ?, datetime('now'))`)
|
||||
.run(studentId, display_name.trim(), cohort.trim());
|
||||
res.json({ student_id: studentId, display_name: display_name.trim(), cohort: cohort.trim() });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/roster/cohorts — distinct cohort names, for the login cohort picker
|
||||
app.get('/api/roster/cohorts', (req, res) => {
|
||||
const rows = getDb().prepare('SELECT DISTINCT cohort FROM students ORDER BY cohort').all();
|
||||
res.json(rows.map(r => r.cohort));
|
||||
});
|
||||
|
||||
// GET /api/roster?cohort=root-fall-2026 — student names for the pick-your-name dropdown
|
||||
app.get('/api/roster', (req, res) => {
|
||||
const { cohort } = req.query;
|
||||
const db = getDb();
|
||||
const rows = cohort
|
||||
? db.prepare('SELECT student_id, display_name, cohort, last_active_at FROM students WHERE cohort = ? ORDER BY display_name').all(cohort)
|
||||
: db.prepare('SELECT student_id, display_name, cohort, last_active_at FROM students ORDER BY cohort, display_name').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/login — student picks their name; no password (see students table comment)
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { student_id } = req.body;
|
||||
const student = getDb().prepare('SELECT student_id, display_name, cohort FROM students WHERE student_id = ?').get(student_id);
|
||||
if (!student) return res.status(404).json({ error: 'Student not found' });
|
||||
touchStudent(student.student_id);
|
||||
res.json(student);
|
||||
});
|
||||
|
||||
// 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 studentId = getStudentId(req);
|
||||
const db = getDb();
|
||||
const resetProgressStmt = db.prepare(`
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ?
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ? AND student_id = ?
|
||||
`);
|
||||
|
||||
try {
|
||||
if (scope === 'scenario') {
|
||||
resetProgressStmt.run(scenarioId);
|
||||
resetProgressStmt.run(scenarioId, studentId);
|
||||
} 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 => resetProgressStmt.run(id)));
|
||||
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id, studentId)));
|
||||
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 => resetProgressStmt.run(id)));
|
||||
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id, studentId)));
|
||||
tx(bundle.scenario_ids);
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Invalid scope' });
|
||||
@@ -285,9 +406,10 @@ app.post('/api/sessions', (req, res) => {
|
||||
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, scenario_ids)
|
||||
VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
|
||||
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds));
|
||||
const studentId = getStudentId(req);
|
||||
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids, student_id)
|
||||
VALUES (?, ?, datetime('now'), 'active', ?, ?, ?)`)
|
||||
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds), studentId);
|
||||
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds });
|
||||
});
|
||||
|
||||
@@ -380,7 +502,8 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
||||
for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
|
||||
|
||||
// Also pull time_spent_seconds from global progress (tracked per scenario during the exam)
|
||||
const globalProgress = loadProgress();
|
||||
// Use the session's own student, not whoever's request is submitting it.
|
||||
const globalProgress = loadProgress(session.student_id || 'unassigned');
|
||||
|
||||
const snapshot = bundleScenarios.map(s => ({
|
||||
id: s.id, title: s.title, weight: s.weight,
|
||||
@@ -396,9 +519,10 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
||||
duration_secs=?, snapshot=? WHERE id=?`)
|
||||
.run(durationSecs, JSON.stringify(snapshot), req.params.id);
|
||||
|
||||
// Reset timers of all scenarios in this bundle
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
|
||||
// Reset timers of all scenarios in this bundle, for this session's student only
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
|
||||
const sid = session.student_id || 'unassigned';
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
|
||||
tx(bundle.scenario_ids);
|
||||
|
||||
res.json({ ok: true, snapshot, durationSecs });
|
||||
@@ -416,8 +540,9 @@ app.post('/api/sessions/:id/abandon', (req, res) => {
|
||||
WHERE id=? AND status='active'`).run(req.params.id);
|
||||
|
||||
if (bundle) {
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
|
||||
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
|
||||
const sid = session.student_id || 'unassigned';
|
||||
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
|
||||
tx(bundle.scenario_ids);
|
||||
}
|
||||
|
||||
@@ -475,7 +600,7 @@ app.post('/api/scenarios/:id/context', async (req, res) => {
|
||||
app.get('/api/bundles', (req, res) => {
|
||||
const bundles = loadBundles();
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
const result = bundles.map(b => {
|
||||
const total = b.scenario_ids.length;
|
||||
const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').length;
|
||||
@@ -484,10 +609,47 @@ app.get('/api/bundles', (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /api/curriculum/path — the guided RootNode learning path, staged with completion status
|
||||
app.get('/api/curriculum/path', (req, res) => {
|
||||
const path = loadCurriculumPath();
|
||||
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
|
||||
let firstIncompleteStage = null;
|
||||
const stages = path.stages.map(stage => {
|
||||
const scenarioList = stage.scenario_ids.map(id => {
|
||||
const s = scenarios.find(x => x.id === id);
|
||||
return {
|
||||
id,
|
||||
title: s?.title || id,
|
||||
difficulty: s?.difficulty || null,
|
||||
status: progress[id]?.status || 'not_started'
|
||||
};
|
||||
});
|
||||
const total = scenarioList.length;
|
||||
const completed = scenarioList.filter(s => s.status === 'completed').length;
|
||||
const isComplete = total > 0 && completed === total;
|
||||
if (!isComplete && !stage.locked && firstIncompleteStage === null) {
|
||||
firstIncompleteStage = stage.id;
|
||||
}
|
||||
return {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
icon: stage.icon,
|
||||
locked: !!stage.locked,
|
||||
scenarios: scenarioList,
|
||||
stats: { total, completed }
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage });
|
||||
});
|
||||
|
||||
// GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
|
||||
app.get('/api/scenarios', (req, res) => {
|
||||
const scenarios = loadScenarios()
|
||||
const progress = loadProgress()
|
||||
const progress = loadProgress(getStudentId(req))
|
||||
const { bundle, session: sessionId } = req.query
|
||||
|
||||
// Session-scoped: return only session scenario_ids in their shuffled order
|
||||
@@ -531,7 +693,7 @@ 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();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
res.json({
|
||||
...scenario,
|
||||
progress: progress[scenario.id] || { status: 'not_started', attempts: 0 }
|
||||
@@ -556,7 +718,8 @@ app.post('/api/scenarios/:id/setup', async (req, res) => {
|
||||
}
|
||||
|
||||
// Mark scenario as in-progress
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') {
|
||||
progress[scenario.id] = {
|
||||
...progress[scenario.id],
|
||||
@@ -564,7 +727,7 @@ app.post('/api/scenarios/:id/setup', async (req, res) => {
|
||||
attempts: (progress[scenario.id]?.attempts || 0),
|
||||
started_at: progress[scenario.id]?.started_at || new Date().toISOString()
|
||||
};
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
}
|
||||
|
||||
res.json({ setup_results: results });
|
||||
@@ -602,7 +765,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
|
||||
}
|
||||
|
||||
// Update global practice progress
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
const prev = progress[scenario.id] || { attempts: 0 };
|
||||
progress[scenario.id] = {
|
||||
...prev,
|
||||
@@ -611,7 +775,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
|
||||
last_validated: new Date().toISOString(),
|
||||
completed_at: allPassed ? new Date().toISOString() : prev.completed_at
|
||||
};
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
touchStudent(studentId);
|
||||
|
||||
// Also update exam_progress if there's an active session
|
||||
const db = getDb();
|
||||
@@ -649,7 +814,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
|
||||
const correct = selected === scenario.correct_option;
|
||||
|
||||
// Update global practice progress
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
const prev = progress[scenario.id] || { attempts: 0 };
|
||||
progress[scenario.id] = {
|
||||
...prev,
|
||||
@@ -659,7 +825,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
|
||||
last_validated: new Date().toISOString(),
|
||||
completed_at: correct ? new Date().toISOString() : prev.completed_at
|
||||
};
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
touchStudent(studentId);
|
||||
|
||||
// Also update exam_progress if there's an active session
|
||||
const db = getDb();
|
||||
@@ -702,7 +869,8 @@ app.post('/api/scenarios/:id/time', (req, res) => {
|
||||
if (!activeSession) {
|
||||
return res.json({ ok: true, message: 'No active session' });
|
||||
}
|
||||
const progress = loadProgress();
|
||||
const studentId = getStudentId(req);
|
||||
const progress = loadProgress(studentId);
|
||||
if (!progress[req.params.id]) {
|
||||
progress[req.params.id] = {
|
||||
status: 'in_progress',
|
||||
@@ -714,14 +882,14 @@ app.post('/api/scenarios/:id/time', (req, res) => {
|
||||
progress[req.params.id].started_at = new Date().toISOString();
|
||||
}
|
||||
progress[req.params.id].time_spent_seconds = time_spent_seconds;
|
||||
saveProgress(progress);
|
||||
saveProgress(studentId, progress);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/progress — full progress summary
|
||||
app.get('/api/progress', (req, res) => {
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress();
|
||||
const progress = loadProgress(getStudentId(req));
|
||||
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);
|
||||
@@ -740,13 +908,13 @@ app.post('/api/progress/reset/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ?
|
||||
`).run(req.params.id);
|
||||
UPDATE progress
|
||||
SET status = 'not_started',
|
||||
attempts = 0,
|
||||
completed_at = NULL,
|
||||
last_validated = NULL
|
||||
WHERE scenario_id = ? AND student_id = ?
|
||||
`).run(req.params.id, getStudentId(req));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
|
||||
Generated
+2996
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@ Scenarios are defined as JSON objects. A scenario can be either a hands-on conso
|
||||
"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
|
||||
"isolation_type": "shared_namespace", // "shared_namespace" | "ephemeral_cluster" — see below
|
||||
"difficulty": "Easy", // "Easy" | "Medium" | "Hard"
|
||||
"type": "task", // "task" | "mcq"
|
||||
"weight": 7, // number — points value (used for final grade scoring)
|
||||
@@ -66,6 +67,18 @@ Scenarios are defined as JSON objects. A scenario can be either a hands-on conso
|
||||
|
||||
---
|
||||
|
||||
### `isolation_type` — informational, not a security boundary
|
||||
|
||||
Earlier draft of this schema used `isolation_type` (`shared_namespace` | `ephemeral_cluster`)
|
||||
to decide which scenarios could safely run under shared-cluster RBAC. That namespace-isolation
|
||||
approach was superseded before it shipped — see `docs/LAB_ARCHITECTURE.md`. Every student now
|
||||
gets a fully separate disposable environment with its own nested cluster, so no scenario
|
||||
content is unsafe for any other student's environment; the field no longer gates anything.
|
||||
It's kept on scenario records as descriptive metadata only (which scenarios exercise
|
||||
cluster-scoped/node-level operations), not read by any authorization logic.
|
||||
|
||||
---
|
||||
|
||||
### Hints Schema
|
||||
Each hint is rendered as a collapsible card inside the Hints tab of the UI:
|
||||
```jsonc
|
||||
@@ -149,6 +162,7 @@ Renders a questionnaire block. No terminal is shown. The user answers by selecti
|
||||
"id": "scale-deployment",
|
||||
"title": "Scale a Deployment",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
@@ -187,6 +201,7 @@ Renders a questionnaire block. No terminal is shown. The user answers by selecti
|
||||
"id": "service-types-mcq",
|
||||
"title": "Kubernetes Service Types",
|
||||
"category": "Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "annotate-resource-basics",
|
||||
"title": "Annotating Kubernetes Resources",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "broken-deployment",
|
||||
"title": "Debug a Failing Deployment",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 8,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-apparmor-profile",
|
||||
"title": "AppArmor Profiles",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-audit-policy",
|
||||
"title": "Write a Kubernetes Audit Policy",
|
||||
"category": "Cluster Setup",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-automount-token",
|
||||
"title": "Disable ServiceAccount Token",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-drop-capabilities",
|
||||
"title": "Drop Linux Capabilities",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-egress-namespace",
|
||||
"title": "Restrict Egress to Namespace",
|
||||
"category": "Network Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-image-pull-secret",
|
||||
"title": "Private Registry Image Pull Secret",
|
||||
"category": "Supply Chain Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-immutable-secret",
|
||||
"title": "Immutable Secrets",
|
||||
"category": "Cluster Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-api-server",
|
||||
"title": "API Server Authentication",
|
||||
"category": "Cluster Setup",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-audit-policy",
|
||||
"title": "Kubernetes Audit Logging Levels",
|
||||
"category": "Cluster Setup",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-image-footprint",
|
||||
"title": "Minimizing Image Footprint",
|
||||
"category": "Supply Chain Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-kubelet-auth",
|
||||
"title": "Securing the Kubelet",
|
||||
"category": "Cluster Setup",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-psp-replacement",
|
||||
"title": "Pod Security Evolution",
|
||||
"category": "Cluster Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-runtime-security",
|
||||
"title": "Runtime Security Tools",
|
||||
"category": "System Hardening",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-mcq-sandboxing",
|
||||
"title": "Container Runtime Sandboxing",
|
||||
"category": "System Hardening",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-network-policy-metadata",
|
||||
"title": "Block Cloud Metadata",
|
||||
"category": "Network Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-network-policy",
|
||||
"title": "Default Deny Network Policy",
|
||||
"category": "Network Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-non-root-enforce",
|
||||
"title": "Enforce Non-Root Container",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-pod-security-context",
|
||||
"title": "Pod Security Context",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-psa-namespace",
|
||||
"title": "Pod Security Admission",
|
||||
"category": "Cluster Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-rbac-clusterrole",
|
||||
"title": "Cluster-Level RBAC",
|
||||
"category": "Cluster Security",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-rbac-least-privilege",
|
||||
"title": "RBAC Least Privilege",
|
||||
"category": "Cluster Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-readonly-filesystem",
|
||||
"title": "Read-Only Root Filesystem",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-runtime-class",
|
||||
"title": "RuntimeClass for Sandboxed Workloads",
|
||||
"category": "System Hardening",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-seccomp-profile",
|
||||
"title": "Seccomp Profiles",
|
||||
"category": "Workload Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cks-tls-ingress",
|
||||
"title": "Secure Ingress with TLS",
|
||||
"category": "Network Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cluster-upgrade-mcq",
|
||||
"title": "Cluster Upgrade Order",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "mcq",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "configmap-volume",
|
||||
"title": "Mount ConfigMap as Volume",
|
||||
"category": "Configuration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "container-logging-mcq",
|
||||
"title": "Container Logging and Monitoring",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "crashloop-fix",
|
||||
"title": "Fix a CrashLoopBackOff Pod",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "cronjob-task",
|
||||
"title": "Create a CronJob",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "daemonset-mcq",
|
||||
"title": "Understanding DaemonSets",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "delete-by-label-basics",
|
||||
"title": "Bulk Deletion by Label",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "deploy-nginx",
|
||||
"title": "Deploy and Expose Nginx",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "deployment-rollback",
|
||||
"title": "Rolling Back a Deployment",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "deployment-strategy-mcq",
|
||||
"title": "Deployment Update Strategies",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "dns-resolution-mcq",
|
||||
"title": "Kubernetes DNS Resolution",
|
||||
"category": "Services & Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "dry-run-manifest-basics",
|
||||
"title": "Generating YAML Manifests",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "edit-deployment-basics",
|
||||
"title": "Updating Deployments",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "emptydir-pod",
|
||||
"title": "Shared emptyDir Volume Between Containers",
|
||||
"category": "Storage",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "endpoint-fix-task",
|
||||
"title": "Fix a Broken Service Selector",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "env-vars-configmap",
|
||||
"title": "Environment Variables from ConfigMap",
|
||||
"category": "Configuration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "etcd-backup-mcq",
|
||||
"title": "etcd Backup and Restore",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "mcq",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "exec-command-basics",
|
||||
"title": "Execute Commands in Pods",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "expose-service-basics",
|
||||
"title": "Exposing an Application",
|
||||
"category": "Services & Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "extract-logs-basics",
|
||||
"title": "Extract Container Logs",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "field-selector-basics",
|
||||
"title": "Filtering Resources with Field Selectors",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "ingress-task",
|
||||
"title": "Create an Ingress Resource",
|
||||
"category": "Services & Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "init-container-task-basics",
|
||||
"title": "Pod with Init Container",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "init-containers-mcq",
|
||||
"title": "Init Containers Behaviour",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "job-task",
|
||||
"title": "Create a One-Shot Job",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "jobs-cronjobs-mcq",
|
||||
"title": "Jobs vs CronJobs",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "jsonpath-basics",
|
||||
"title": "JSONPath Data Extraction",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "kubectl-cp-basics",
|
||||
"title": "Copy Files to Containers",
|
||||
"category": "Troubleshooting",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "kubectl-diff-basics",
|
||||
"title": "Previewing Changes with kubectl diff",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "kubectl-essentials-mcq",
|
||||
"title": "Essential kubectl Commands",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "kubectl-explain-basics",
|
||||
"title": "Exploring the API with kubectl explain",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "kubectl-output-format-mcq",
|
||||
"title": "kubectl Output Formats",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 2,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "kubectl-patch-basics",
|
||||
"title": "Patching Resources with kubectl patch",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "labels-selectors-mcq",
|
||||
"title": "Labels and Selectors",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "limitrange-task",
|
||||
"title": "LimitRange for Default Container Limits",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "liveness-probe-task",
|
||||
"title": "Configure a Liveness Probe",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "multi-container-pod-basics",
|
||||
"title": "Create a Multi-Container Pod",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "namespaces-basics",
|
||||
"title": "Working with Namespaces",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "network-policy",
|
||||
"title": "Isolate Traffic with NetworkPolicy",
|
||||
"category": "Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 9,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "networkpolicy-egress",
|
||||
"title": "Egress NetworkPolicy",
|
||||
"category": "Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 8,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "node-cordon-basics",
|
||||
"title": "Cordoning and Uncordoning a Node",
|
||||
"category": "Cluster Management",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "node-label-selector",
|
||||
"title": "Node Labels and nodeSelector",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "node-taint-toleration",
|
||||
"title": "Taints and Tolerations",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "nodeport-task",
|
||||
"title": "Expose a Deployment via NodePort",
|
||||
"category": "Services & Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-affinity-mcq",
|
||||
"title": "Pod Affinity vs Anti-Affinity",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-basics-mcq",
|
||||
"title": "Pod Lifecycle States",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-disruption-budget-task",
|
||||
"title": "Configure a PodDisruptionBudget",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-env-vars-inline",
|
||||
"title": "Pod with Inline Environment Variables",
|
||||
"category": "Configuration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-labeling-basics",
|
||||
"title": "Labelling Resources",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-node-assignment-mcq",
|
||||
"title": "Assigning Pods to Nodes",
|
||||
"category": "Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pod-security-context",
|
||||
"title": "Pod Security Context",
|
||||
"category": "Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "priority-class-task",
|
||||
"title": "Pod Priority and PriorityClass",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pv-pvc-mount",
|
||||
"title": "Persistent Storage with PV and PVC",
|
||||
"category": "Storage",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "pvc-dynamic-task",
|
||||
"title": "Dynamic PVC Provisioning",
|
||||
"category": "Storage",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "rbac-clusterrole",
|
||||
"title": "ClusterRole and ClusterRoleBinding",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 8,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "rbac-role",
|
||||
"title": "RBAC: Create Role and RoleBinding",
|
||||
"category": "Security",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 9,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "readiness-probe-task",
|
||||
"title": "Configure a Readiness Probe",
|
||||
"category": "Workloads & Scheduling",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 6,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "resource-limits-task",
|
||||
"title": "Resource Requests and Limits",
|
||||
"category": "Configuration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "resource-quota-ns",
|
||||
"title": "ResourceQuota for a Namespace",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "restart-policy-mcq",
|
||||
"title": "Pod Restart Policies",
|
||||
"category": "Core Concepts",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "rolling-update-task",
|
||||
"title": "Perform a Rolling Update",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "rootnode-enforce-limits",
|
||||
"title": "Stop the Next Outage: Enforce Resource Defaults",
|
||||
"category": "Observability & Ops",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Hard",
|
||||
"type": "task",
|
||||
"weight": 10,
|
||||
@@ -24,7 +25,9 @@
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{ "command": "kubectl create namespace workloads --dry-run=client -o yaml | kubectl apply -f -" }
|
||||
{
|
||||
"command": "kubectl create namespace workloads --dry-run=client -o yaml | kubectl apply -f -"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"description": "Checks that the LimitRange exists with the correct default values.",
|
||||
@@ -63,7 +66,11 @@
|
||||
},
|
||||
"default_namespace": "workloads",
|
||||
"teardown_commands": [
|
||||
{ "command": "kubectl delete limitrange resource-defaults -n workloads --ignore-not-found" },
|
||||
{ "command": "kubectl delete namespace workloads --ignore-not-found --wait=false" }
|
||||
{
|
||||
"command": "kubectl delete limitrange resource-defaults -n workloads --ignore-not-found"
|
||||
},
|
||||
{
|
||||
"command": "kubectl delete namespace workloads --ignore-not-found --wait=false"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "rootnode-probe-port-mismatch",
|
||||
"title": "Stuck Not-Ready: The Probe Port Mismatch",
|
||||
"category": "Observability & Ops",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 8,
|
||||
@@ -24,8 +25,12 @@
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{ "command": "kubectl create deployment api-gateway --image=nginx:1.25 --replicas=2" },
|
||||
{ "command": "kubectl patch deployment api-gateway --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/readinessProbe\",\"value\":{\"httpGet\":{\"path\":\"/\",\"port\":8080},\"initialDelaySeconds\":3,\"periodSeconds\":5}}]'" }
|
||||
{
|
||||
"command": "kubectl create deployment api-gateway --image=nginx:1.25 --replicas=2"
|
||||
},
|
||||
{
|
||||
"command": "kubectl patch deployment api-gateway --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/readinessProbe\",\"value\":{\"httpGet\":{\"path\":\"/\",\"port\":8080},\"initialDelaySeconds\":3,\"periodSeconds\":5}}]'"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"description": "Checks that the probe port is corrected and both replicas are Ready.",
|
||||
@@ -52,6 +57,8 @@
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{ "command": "kubectl delete deployment api-gateway --ignore-not-found" }
|
||||
{
|
||||
"command": "kubectl delete deployment api-gateway --ignore-not-found"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "rootnode-runaway-monitor",
|
||||
"title": "Recover the Monitoring Stack",
|
||||
"category": "Observability & Ops",
|
||||
"isolation_type": "ephemeral_cluster",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 8,
|
||||
@@ -24,9 +25,15 @@
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{ "command": "kubectl create namespace monitoring" },
|
||||
{ "command": "kubectl create deployment node-metrics --image=nginx:1.25 --replicas=1 -n monitoring" },
|
||||
{ "command": "kubectl set resources deployment node-metrics -n monitoring --requests=memory=64Gi" }
|
||||
{
|
||||
"command": "kubectl create namespace monitoring"
|
||||
},
|
||||
{
|
||||
"command": "kubectl create deployment node-metrics --image=nginx:1.25 --replicas=1 -n monitoring"
|
||||
},
|
||||
{
|
||||
"command": "kubectl set resources deployment node-metrics -n monitoring --requests=memory=64Gi"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"description": "Checks that node-metrics is right-sized: scheduled, 1/1 Ready, and still declaring a memory request.",
|
||||
@@ -53,6 +60,8 @@
|
||||
},
|
||||
"default_namespace": "monitoring",
|
||||
"teardown_commands": [
|
||||
{ "command": "kubectl delete namespace monitoring --ignore-not-found --wait=false" }
|
||||
{
|
||||
"command": "kubectl delete namespace monitoring --ignore-not-found --wait=false"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "scale-deployment",
|
||||
"title": "Scaling a Deployment",
|
||||
"category": "Workloads",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "secrets-basics",
|
||||
"title": "Creating and Using Secrets",
|
||||
"category": "Configuration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "serviceaccount-pod",
|
||||
"title": "ServiceAccount for Pod API Access",
|
||||
"category": "Cluster Administration",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 7,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "services-mcq",
|
||||
"title": "Kubernetes Service Types",
|
||||
"category": "Networking",
|
||||
"isolation_type": "shared_namespace",
|
||||
"difficulty": "Easy",
|
||||
"type": "mcq",
|
||||
"weight": 4,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user