Compare commits

..

2 Commits

Author SHA1 Message Date
vumas3r
fe86b4be35 chore: release 2026.10.26.3
Bump VERSION to 2026.10.26.3 and date its CHANGELOG.md entry: the
stale ./ksim web guard from 95f9d1b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:42:24 -06:00
vumas3r
95f9d1b7ee fix: start nothing from a page newer than its ./ksim web
./ksim web reads web/index.html from disk on every request but runs
the code it started with. After the Teach a domain update, a server
from before it got the page's new start request, did not know
"teach", and started its default instead: a full 20-task exam.

Report "stale" in /api/session: whether ksim or sim/*.py changed on
disk since the server started. The page treats a server that sends
no "stale" as stale too, since it predates the check. While the
server is stale, the lobby says to restart it and launch() starts no
session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:42:16 -06:00
3 changed files with 32 additions and 1 deletions

View File

@ -4,6 +4,18 @@ Notable changes to kubestronaut-sim, newest first. A version is the `VERSION` st
(year.month.day.n); `./ksim help` and the top bar of the web page show the one you are running. (year.month.day.n); `./ksim help` and the top bar of the web page show the one you are running.
Every entry names the commit it came in. Every entry names the commit it came in.
## 2026.10.26.3 — 2026-10-26
Everything below came in `95f9d1b`.
### Fixed
- Choosing *Teach a domain* could start a full exam. `./ksim web` reads `web/index.html` afresh on
every request but keeps running the code it started with, so a server from before the update
read the new request as its default, a full exam. The server now reports when its code has
changed on disk since it started (a server from before this check counts as changed). The
lobby then asks you to restart it and starts nothing until you do.
## 2026.10.26.2 — 2026-10-26 ## 2026.10.26.2 — 2026-10-26
Everything below came in `e852419`. Everything below came in `e852419`.

15
ksim
View File

@ -22,7 +22,7 @@ from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
VERSION = "2026.10.26.2" VERSION = "2026.10.26.3"
ROOT = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
from sim import desktop, envs # noqa: E402 from sim import desktop, envs # noqa: E402
@ -2165,9 +2165,17 @@ def cmd_reset(args):
print(("rebuilt " if not problem else "problem rebuilding ") + f"Q{q['id']:02d}: {q['title']}") print(("rebuilt " if not problem else "problem rebuilding ") + f"Q{q['id']:02d}: {q['title']}")
# --------------------------------------------------------------- web ---- # --------------------------------------------------------------- web ----
def code_stamp():
"""When the simulator's own code (ksim, sim/*.py) last changed on disk."""
sim_dir = os.path.join(ROOT, "sim")
return max(os.path.getmtime(f) for f in [os.path.join(ROOT, "ksim")]
+ [os.path.join(sim_dir, n) for n in os.listdir(sim_dir) if n.endswith(".py")])
class Api(http.server.BaseHTTPRequestHandler): class Api(http.server.BaseHTTPRequestHandler):
"""Serves web/index.html plus a small JSON API. State lives in the same files the CLI uses.""" """Serves web/index.html plus a small JSON API. State lives in the same files the CLI uses."""
lock = threading.Lock() lock = threading.Lock()
started_code = None # code_stamp() when ./ksim web started
log_lines = [] log_lines = []
seeding = False seeding = False
seed_error = None seed_error = None
@ -2345,6 +2353,10 @@ class Api(http.server.BaseHTTPRequestHandler):
"k8s": k8s_version(), "pending": len(PENDING), "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()}, "k8s": k8s_version(), "pending": len(PENDING), "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()},
"seen": sorted(last_seen()), "seen": sorted(last_seen()),
"seeding": Api.seeding, "seed_error": Api.seed_error, "seeding": Api.seeding, "seed_error": Api.seed_error,
# index.html is read from disk on every request, the server's code only when it started:
# after an update the page may ask for what this server does not know (an old one read
# "teach a domain" as a full exam), so the page starts nothing until it is restarted
"stale": Api.started_code is not None and code_stamp() > Api.started_code,
"session": None, "session": None,
} }
payload.update(self.path_payload()) payload.update(self.path_payload())
@ -2444,6 +2456,7 @@ def cmd_web(args):
except envs.EnvError as e: except envs.EnvError as e:
Api.desktop_error = str(e) Api.desktop_error = str(e)
print(yellow(f" no desktop: {e}")) print(yellow(f" no desktop: {e}"))
Api.started_code = code_stamp()
http.server.ThreadingHTTPServer.allow_reuse_address = True http.server.ThreadingHTTPServer.allow_reuse_address = True
srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Api) # type: ignore[arg-type] srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Api) # type: ignore[arg-type]
print(bold(f"\n {TRACK_ID.upper()} exam UI: http://127.0.0.1:{port} (ksim {VERSION})\n") print(bold(f"\n {TRACK_ID.upper()} exam UI: http://127.0.0.1:{port} (ksim {VERSION})\n")

View File

@ -397,6 +397,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
<div class="scrollhint">SCROLL FOR YOUR PATH AND OBJECTIVES</div> <div class="scrollhint">SCROLL FOR YOUR PATH AND OBJECTIVES</div>
</section> </section>
<main class="wrap"> <main class="wrap">
<div class="alert alert-warning hidden" id="staleNote"></div>
<div class="alert alert-danger hidden" id="lobbyErr"></div> <div class="alert alert-danger hidden" id="lobbyErr"></div>
<div class="alert alert-info hidden" id="lobbyNote"></div> <div class="alert alert-info hidden" id="lobbyNote"></div>
<section class="card" id="pathCard"> <section class="card" id="pathCard">
@ -596,6 +597,9 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const fmtHMS = s => { s = Math.max(0, Math.floor(s)); return [s / 3600, s % 3600 / 60, s % 60].map(pad).join(':'); }; const fmtHMS = s => { s = Math.max(0, Math.floor(s)); return [s / 3600, s % 3600 / 60, s % 60].map(pad).join(':'); };
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c])); const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const oldServer = what => `This ./ksim web was started before "${what}" existed. Stop it (Ctrl-C) and start it again.`; const oldServer = what => `This ./ksim web was started before "${what}" existed. Stop it (Ctrl-C) and start it again.`;
// a server that sends no "stale" predates the check, so it is older than this page as well
const STALE = 'The simulator was updated after this ./ksim web started, and the server still runs the old code: it would misread what this page asks for (an old one starts a full exam instead of a domain\'s lessons). Stop it (Ctrl-C), start it again, and reload this page.';
const stale = () => S && S.stale !== false;
const openModal = id => $(id).classList.remove('hidden'); const openModal = id => $(id).classList.remove('hidden');
const closeModal = id => $(id).classList.add('hidden'); const closeModal = id => $(id).classList.add('hidden');
const live = () => !!(S && S.session && !S.session.ended_at); const live = () => !!(S && S.session && !S.session.ended_at);
@ -683,6 +687,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$$('#trackPick [data-track]').forEach(el => el.onclick = () => switchTrack(el.dataset.track)); $$('#trackPick [data-track]').forEach(el => el.onclick = () => switchTrack(el.dataset.track));
$('#ver').textContent = 'ksim ' + (S.version || '?') + (S.pending ? ` · ${S.pending} task(s) held back` : ''); $('#ver').textContent = 'ksim ' + (S.version || '?') + (S.pending ? ` · ${S.pending} task(s) held back` : '');
$('#foot').textContent = `ksim ${S.version || '?'} · Apache-2.0 · not affiliated with the CNCF or the Linux Foundation`; $('#foot').textContent = `ksim ${S.version || '?'} · Apache-2.0 · not affiliated with the CNCF or the Linux Foundation`;
$('#staleNote').textContent = STALE; $('#staleNote').classList.toggle('hidden', !stale());
$('#heroTitle').textContent = T.name; $('#heroTitle').textContent = T.name;
$('#heroSub').textContent = `Kubernetes ${T.k8s}. Every task runs on its own cluster that you reach with ssh from a remote desktop, and every session runs against the clock.`; $('#heroSub').textContent = `Kubernetes ${T.k8s}. Every task runs on its own cluster that you reach with ssh from a remote desktop, and every session runs against the clock.`;
$('#hardHero').checked = hard; $('#hardHero').checked = hard;
@ -891,6 +896,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
} }
} }
async function launch(body) { async function launch(body) {
if (stale()) { closeModal('#mPicker'); return lobbyError(STALE); }
closeModal('#mPicker'); $('#lobbyErr').classList.add('hidden'); $('#lobbyNote').classList.add('hidden'); closeModal('#mPicker'); $('#lobbyErr').classList.add('hidden'); $('#lobbyNote').classList.add('hidden');
$('#prepMsg').textContent = 'starting…'; $('#prepLog').textContent = ''; openModal('#mPrep'); polling = true; $('#prepMsg').textContent = 'starting…'; $('#prepLog').textContent = ''; openModal('#mPrep'); polling = true;
try { await api('/api/start', body); await waitSeeded(); ready(true); } try { await api('/api/start', body); await waitSeeded(); ready(true); }