546 lines
20 KiB
JavaScript
546 lines
20 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync, exec } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const execAsync = promisify(exec);
|
|
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_DIR = path.join(__dirname, '../scenarios/data');
|
|
const BUNDLES_DIR = path.join(__dirname, '../scenarios/bundles');
|
|
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 loadJsonDir(dir) {
|
|
return fs.readdirSync(dir)
|
|
.filter(f => f.endsWith('.json'))
|
|
.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
|
}
|
|
|
|
function loadScenarios() {
|
|
return loadJsonDir(SCENARIOS_DIR);
|
|
}
|
|
|
|
function loadBundles() {
|
|
return loadJsonDir(BUNDLES_DIR);
|
|
}
|
|
|
|
async function runCommand(cmd, timeoutMs = 15000) {
|
|
try {
|
|
const { stdout } = await execAsync(cmd, {
|
|
timeout: timeoutMs,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG || '/root/.kube/config' }
|
|
});
|
|
return { success: true, output: stdout.trim() };
|
|
} catch (e) {
|
|
return { success: false, output: (e.stdout || '').trim(), error: (e.stderr || e.message || '').trim() };
|
|
}
|
|
}
|
|
|
|
function checkMatch(actual, expected, matchType) {
|
|
const a = String(actual).trim();
|
|
const e = String(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', async (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 = await 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', async (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
|
|
await 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=<id> 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', async (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 = await 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', async (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 = await 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 <missing-resource> 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', async (req, res) => {
|
|
const kube = await 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}`));
|
|
|