From e285469fa0acf509a13d29a095b5e433bed8f81b Mon Sep 17 00:00:00 2001 From: Luis-ADFA Date: Tue, 1 Sep 2026 09:51:16 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Phase=201=20**server=20half**=20of=20the=20?= =?UTF-8?q?Dashboard-update=20card.=20Two=20additive,=20localhost-only=20e?= =?UTF-8?q?ndpoints=20that=20the=20UI=20surfaces=20(Update=E2=87=84Rebuild?= =?UTF-8?q?=20button,=20expandable=20Details,=20the=20confirm-dialog=20che?= =?UTF-8?q?ckbox)=20will=20build=20on=20in=20their=20own=20PRs.=20Nothing?= =?UTF-8?q?=20user-facing=20here;=20verifiable=20entirely=20by=20`curl`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dash-node serves two separate artifacts from one process: the **REST core** (`static/dashboard/`, has a `package.json` version) and the **landing site** (`static/site/`, no version of its own). The version belongs strictly to the core; the site never carries one. This PR honours that by construction — `site-updater.sh` touches neither `package.json` nor the reported version, so the core can never present a "site version". - **`GET /system/dashboard/rebuild/log`** — read-only tail of `/var/log/dash-rebuild.log` (last ~200 lines; a whole rebuild fits, so the client replaces the panel each poll rather than tracking a cursor). No rebuild ever ran = empty log, not an error. - **`POST /system/dashboard/rebuild` accepts `{ site: true }`** → passes `K2GO_SITE=1` to the script; the response echoes `site`. Absent/false keeps the previous behaviour exactly. - **`rebuild-dashboard.sh`** runs `site-updater.sh` in **finalize** — from the same clone the rebuild's `git fetch+reset` just refreshed, and only **after** the core swap has verified live, so the deployed site matches the new source. It is **best-effort**: a site failure is logged, never a rollback of the already-verified core update. Runs on any rebuild (Update or a same-version Rebuild), which is why the site action is versionless and idempotent. Lifecycle worth stating: the site deploys **only if the core reached finalize**. If the staging build or smoke test fails, finalize is never reached and the site is untouched — it matches the new source or is not deployed, never half. dash-node **1.2.11 → 1.2.12** (both changes are REST-facing; CHANGELOG updated). - Shell syntax checked (`sh -n` / `bash -n`) on both scripts. - TypeScript reviewed by hand; **not** typechecked here (no Node on the build machine) — the deploy's `yarn build` (= `tsc`) is the real gate, same as ADFA-5361. - On device, after deploying this branch: --- static/dashboard/CHANGELOG.md | 1 + static/dashboard/package.json | 2 +- static/dashboard/routes.ts | 35 ++++++++++++++++++++++++++++++++--- tools/rebuild-dashboard.sh | 13 +++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/static/dashboard/CHANGELOG.md b/static/dashboard/CHANGELOG.md index b65a2adaa..72fc339ee 100644 --- a/static/dashboard/CHANGELOG.md +++ b/static/dashboard/CHANGELOG.md @@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i (the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`. +- **1.2.12** - Dashboard-update card back end (ADFA-5339, Phase 1 server half). New read-only `GET /system/dashboard/rebuild/log`: the last ~200 lines of `/var/log/dash-rebuild.log`, for the card's expandable "Details" (no file yet = empty log, not an error). `POST /system/dashboard/rebuild` now accepts `{ site: true }`: it refreshes the served landing page in the SAME run via `site-updater.sh`, from the same clone the rebuild's git fetch+reset refreshes, in finalize AFTER the core swap verifies live — so the site matches the new source. The site is a separate, versionless artifact: it never touches the reported version, and a site failure is logged, never a rollback of the (already-verified) core update. Both localhost-only. (ADFA-5339) - **1.2.11** - `/auth/:service/session` mints the session **for the agent that asks** (ADFA-5361). Calibre-Web (Flask-Login) binds a session to a fingerprint of the User-Agent, so a session minted under dash-node's own agent was rejected on the WebView's first request: the identity was dropped, the `remember_token` deleted, and the card opened as the anonymous Guest — the "logged in as Admin" flash comes from the injected session and renders even then, which is why the auto-login looked like it worked. The route now forwards the caller's `User-Agent` through the whole login handshake (every request, not just the POST — the fingerprint is established on the first one), for Calibre-Web and Kolibri alike. The callers that consume the session themselves (downloads runner, `removeBook`) are unchanged. No User-Agent on the request degrades to the previous behaviour, logged. Same ticket: the books runner's private copy of the Calibre-Web login is gone — it never got the ADFA-5043 `remember_me` and was the drift this whole bug rode in on — so `getCalibreSession` is the one source. (ADFA-5361) - **1.2.10** - In-proot content-service recovery (ADFA-5343, ADR-5343a §10). New `POST /system/service/:svc/restart`: runs `pdsm restart ` in the one living proot to recover a content service wedged after an environment relaunch (orphaned off proot → `epoll_wait` ENOSYS), for the supported upstream services (mirrors `pdsm_installed_services`; `dash-node` excluded). A server-side watcher auto-heals a present-but-wedged content service (404 = not installed → left alone), cooldown-bounded; the app's future module-card Retry is the manual backstop hitting the same endpoint. Loopback-only, like all of `/k2go-api`. (ADFA-5343) - **1.2.9** - Cancelable dashboard self-update (ADFA-5333). New `POST /system/dashboard/rebuild/cancel`: stops an in-flight rebuild cleanly while it is still **building** (git fetch + staging build + smoke test — none of which touch the live dashboard) by signaling the detached `setsid` session group; **refused during "promoting"** (the short dist-swap + restart window) so the swap is never interrupted mid-flight, and a no-op (409) when nothing is running. To support this, `tools/rebuild-dashboard.sh` now records its coarse phase (`building`/`promoting`) and its session-leader pid, and its cleanup trap fires on TERM/INT so a canceled run leaves no staging behind. Pairs with the app running the update in the background with a Cancel action (ADFA-5333). (ADFA-5333) diff --git a/static/dashboard/package.json b/static/dashboard/package.json index 7f7e063b0..03c376746 100644 --- a/static/dashboard/package.json +++ b/static/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "dashboard-console", - "version": "1.2.11", + "version": "1.2.12", "description": "", "main": "index.js", "scripts": { diff --git a/static/dashboard/routes.ts b/static/dashboard/routes.ts index 38db6c750..123e75bb4 100644 --- a/static/dashboard/routes.ts +++ b/static/dashboard/routes.ts @@ -250,6 +250,11 @@ const REBUILD_STATUS_FILE = '/var/run/dash-rebuild.status'; const REBUILD_PHASE_FILE = '/var/run/dash-rebuild.phase'; const REBUILD_PID_FILE = '/var/run/dash-rebuild.pid'; const REBUILD_LOCK_DIR = '/var/run/dash-rebuild.lock'; +// ADFA-5339: the rebuild script's own log; the card's expandable Details tails it. Read-only. +const REBUILD_LOG_FILE = '/var/log/dash-rebuild.log'; +// How many trailing lines the Details tail returns. A whole rebuild is tens of lines, so this holds +// the entire log with headroom; the client replaces the panel each poll rather than tracking a cursor. +const REBUILD_LOG_TAIL = 200; // ADFA-5051: the remote branch that update-check compares against AND the rebuild pulls. Defaults to // mainline; set K2GO_DASH_BRANCH in the dash-node env to point a test box at a feature branch (e.g. // to exercise the live self-update before merging to main) without touching code. Keep it unset in @@ -273,26 +278,50 @@ apiRouter.get('/system/dashboard/rebuild/status', (_req: Request, res: Response) res.json({ state }); }); +// ADFA-5339: read-only tail of the rebuild log, for the card's expandable Details. Returns the last +// REBUILD_LOG_TAIL lines (a whole rebuild fits); the client replaces the panel each poll. No file yet +// (no rebuild ever ran) is not an error — it is an empty log. Localhost-only, like all of /k2go-api. +apiRouter.get('/system/dashboard/rebuild/log', (_req: Request, res: Response): void => { + res.set('Cache-Control', 'no-store'); + let lines: string[] = []; + try { + const raw = fs.readFileSync(REBUILD_LOG_FILE, 'utf8'); + // Split, drop a trailing empty line, keep the last N. Reading the whole file is fine at this + // size; if the log ever grows unbounded this is the place to switch to a byte-bounded tail. + const all = raw.split('\n'); + if (all.length && all[all.length - 1] === '') all.pop(); + lines = all.slice(-REBUILD_LOG_TAIL); + } catch { /* no log yet: leave lines empty */ } + res.json({ lines }); +}); + // Trigger a rebuild. Fire-and-forget: launches the orchestrator DETACHED and returns 202 at once; // the app then polls /system/version + RestReadiness until the API is back on the new version. -apiRouter.post('/system/dashboard/rebuild', (_req: Request, res: Response): void => { +// ADFA-5339: an optional { site: true } also refreshes the served landing page in the same run. The +// site is a SEPARATE artifact with no version of its own — it is deployed by site-updater.sh from the +// same clone the rebuild's git fetch+reset refreshes, in finalize AFTER the core swap verifies live, +// so it matches the new source. It never touches the reported version; a site failure is logged and +// does NOT fail the (already-verified) core update. See ADFA-5339 §semantics. +apiRouter.post('/system/dashboard/rebuild', (req: Request, res: Response): void => { let running = false; try { running = fs.readFileSync(REBUILD_STATUS_FILE, 'utf8').trim() === 'running'; } catch { /* none */ } if (running) { res.status(409).json({ error: 'a rebuild is already running' }); return; } if (!fs.existsSync(REBUILD_SCRIPT)) { res.status(500).json({ error: 'rebuild script not found' }); return; } + const updateSite = (req.body as { site?: unknown })?.site === true; try { // setsid => own session, so `pdsm restart dash-node` inside the script can't kill this run. // Pass the tracked branch so the REST rebuild and update-check always agree on the source. const child = spawn('setsid', ['sh', REBUILD_SCRIPT], { detached: true, stdio: 'ignore', - env: { ...process.env, K2GO_BRANCH: DASH_BRANCH }, + // ADFA-5339: K2GO_SITE gates the finalize-time site deploy; absent/0 keeps the old behaviour. + env: { ...process.env, K2GO_BRANCH: DASH_BRANCH, K2GO_SITE: updateSite ? '1' : '0' }, }); child.unref(); // ADFA-5051: mark "running" synchronously here, before we answer. The detached script also sets // it, but not until it starts — so a client that polls immediately could otherwise read the // PREVIOUS run's "done"/"error" and report a false instant success. Writing it now closes that race. try { fs.writeFileSync(REBUILD_STATUS_FILE, 'running'); } catch { /* best effort */ } - res.status(202).json({ ok: true, state: 'running' }); + res.status(202).json({ ok: true, state: 'running', site: updateSite }); } catch (e: any) { res.status(500).json({ error: e?.message || 'could not start rebuild' }); } diff --git a/tools/rebuild-dashboard.sh b/tools/rebuild-dashboard.sh index dbc447acf..7d34a774a 100755 --- a/tools/rebuild-dashboard.sh +++ b/tools/rebuild-dashboard.sh @@ -159,6 +159,19 @@ if verify_live; then # nginx reads /etc/nginx/conf.d, not /library/dashboard, so mirror the vhost then reload nginx. [ -f "$LIVE/dash-node-nginx.conf" ] && { cp -f "$LIVE/dash-node-nginx.conf" "$NGINX_CONF_DIR/dash-node-nginx.conf"; chmod 0600 "$NGINX_CONF_DIR/dash-node-nginx.conf"; } /usr/local/bin/pdsm restart nginx >>"$LOG" 2>&1 || log "warn: pdsm restart nginx returned non-zero" + # ADFA-5339: optionally refresh the served landing page, from the SAME clone the git fetch+reset + # above just refreshed, so it matches the new source. Runs only here — after the core swap has + # verified live — and is best-effort: the site is a separate, versionless artifact, so a failure is + # a warning, never a rollback of the core update that already succeeded. K2GO_SITE=1 opts in. + if [ "${K2GO_SITE:-0}" = "1" ]; then + SITE_UPDATER="$CLONE_DIR/static/site/site-updater.sh" + if [ -f "$SITE_UPDATER" ]; then + log "updating the served website (site-updater)" + sh "$SITE_UPDATER" >>"$LOG" 2>&1 || log "warn: website update failed (core update succeeded)" + else + log "warn: K2GO_SITE=1 but site-updater not found at $SITE_UPDATER (core update succeeded)" + fi + fi log "rebuild complete" rm -rf "$BACKUP" set_status "done" From b0d029ec96ce0b16550cbab5d86b4a18a921a6e7 Mon Sep 17 00:00:00 2001 From: Luis-ADFA Date: Tue, 1 Sep 2026 10:33:15 -0600 Subject: [PATCH 2/3] ADFA-5339 fix(rebuild): invoke site-updater with bash, not sh --- tools/rebuild-dashboard.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/rebuild-dashboard.sh b/tools/rebuild-dashboard.sh index 7d34a774a..ea88263ab 100755 --- a/tools/rebuild-dashboard.sh +++ b/tools/rebuild-dashboard.sh @@ -167,7 +167,9 @@ if verify_live; then SITE_UPDATER="$CLONE_DIR/static/site/site-updater.sh" if [ -f "$SITE_UPDATER" ]; then log "updating the served website (site-updater)" - sh "$SITE_UPDATER" >>"$LOG" 2>&1 || log "warn: website update failed (core update succeeded)" + # site-updater.sh is a bash script (uses ${BASH_SOURCE[0]}, arrays); run it with bash, not + # this sh — dash trips on the bash-isms and mis-resolves its own source dir (ADFA-5339). + bash "$SITE_UPDATER" >>"$LOG" 2>&1 || log "warn: website update failed (core update succeeded)" else log "warn: K2GO_SITE=1 but site-updater not found at $SITE_UPDATER (core update succeeded)" fi From 45faeed17f35e39c9216ea52419636494e821ee8 Mon Sep 17 00:00:00 2001 From: Luis-ADFA Date: Tue, 1 Sep 2026 10:54:03 -0600 Subject: [PATCH 3/3] ADFA-5339 fix(site): validate the source before wiping the destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit site-updater.sh mirrors static/site into the served home dir, but it wiped the destination BEFORE checking the source — so a mis-resolved SITE_SRC (what the sh-vs-bash bug produced: /root) deleted the served site and copied the wrong tree with no rollback. The `[ -d ]` guard was too weak: a wrong-but-existing dir passes it. Replaced with `[ -f "$SITE_SRC/index.html" ]`, which only the real site satisfies — one stronger guard instead of two, and it refuses (printing why, changing nothing) instead of destroying on a bad source. Device-caught while verifying ADFA-5339. --- static/site/site-updater.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/static/site/site-updater.sh b/static/site/site-updater.sh index e842ab1d2..846e21723 100755 --- a/static/site/site-updater.sh +++ b/static/site/site-updater.sh @@ -15,7 +15,15 @@ DEST_DIR="/library/www/html/home" printf "\n${CYAN}Deploying the landing site...${NC}\n" -[ -d "$SITE_SRC" ] || { printf "${RED}Source not found: $SITE_SRC${NC}\n"; exit 1; } +# ADFA-5339: validate the source is the REAL site BEFORE touching the destination. The mirror below +# is destructive (it wipes DEST first), so a mis-resolved SITE_SRC would delete the served site and +# copy the wrong tree with no rollback. `[ -d ]` is not enough — a wrong-but-existing dir (e.g. /root +# when SITE_SRC resolved empty) passes it; require the landing page's own index.html so only the +# actual site can proceed. Caught on device when this script was invoked with sh instead of bash. +[ -f "$SITE_SRC/index.html" ] || { + printf "${RED}Refusing to deploy: %s is not the site (no index.html). Nothing was changed.${NC}\n" "$SITE_SRC" + exit 1 +} mkdir -p "$DEST_DIR" # Mirror: clear the destination, then copy via tar (proot-safe). Excludes this script and any *.sh.