11#! /bin/sh
2- # tools/rebuild-dashboard.sh [CLONE_DIR] — ADFA-5011
2+ # tools/rebuild-dashboard.sh [CLONE_DIR] — ADFA-5011 / ADFA-5051
33#
44# Rebuild ONLY the dash-node REST API from the on-device clone, without a rootfs rebuild.
5- # Blue-green + verify-before-swap so the live API is never left in a broken state:
5+ # Blue-green + verify-before-commit so the live API is never left in a broken or misreporting state:
66#
77# 1. git fetch + reset --hard origin/<branch> (deterministic; the box clone isn't edited)
88# 2. build in a STAGING dir (yarn install + build)
99# 3. smoke-test the STAGED build on a temp port (tools/dashboard-smoketest.sh)
10- # 4. only if it passes: back up live dist, atomically swap staged dist in, sync source + nginx,
11- # restart dash-node + nginx, and re-verify LIVE
12- # 5. if the live check fails: roll back to the backed-up dist and restart
10+ # 4. only if it passes: back up live dist, sync node_modules, atomically swap the dist in, restart
11+ # dash-node, and verify LIVE
12+ # 5a. live OK -> FINALIZE: only now advance source + package.json + nginx vhost to match the running
13+ # dist (so the reported version can never get ahead of the code), reload nginx, done.
14+ # 5b. live FAIL -> roll the dist back, restart, and RE-VERIFY the rollback recovered. Because source +
15+ # package.json were never advanced (5a), a failed update leaves NO version/source drift.
16+ #
17+ # ADFA-5051 also hardens: proot ".l2s." symlink-loop artifacts no longer break the node_modules copy or
18+ # the staging cleanup (see purge_staging), and the backup is cleaned on every path.
1319#
1420# If step 1-3 fail, the LIVE dashboard is never touched. Launched DETACHED (setsid) by
1521# POST /api/system/dashboard/rebuild, so the `pdsm restart dash-node` in step 4 cannot kill
@@ -34,7 +40,24 @@ LOCK="/var/run/dash-rebuild.lock"
3440
3541log () { echo " [$( date ' +%Y-%m-%d %H:%M:%S' ) ] $* " >> " $LOG " 2> /dev/null; }
3642set_status () { echo " $1 " > " $STATUS " 2> /dev/null || true ; }
37- cleanup () { [ -n " ${TESTPID:- } " ] && kill " $TESTPID " 2> /dev/null || true ; rm -rf " $STAGE " ; rmdir " $LOCK " 2> /dev/null || true ; }
43+ # ADFA-5051: proot renders some native-build symlinks (e.g. in better-sqlite3/build) as ".l2s." loops
44+ # that `rm -rf` can't recurse into (ELOOP -> "Directory not empty"), but a direct unlink removes them.
45+ # Sweep them first, then remove the tree — so staging is always cleanly removable.
46+ purge_staging () {
47+ [ -d " $STAGE " ] || return 0
48+ find " $STAGE " -name ' *.l2s.*' -exec rm -f {} + 2> /dev/null || true
49+ rm -rf " $STAGE " 2> /dev/null || true
50+ }
51+ # ADFA-5051: poll the LIVE API's smoke test until it passes (~30s) or give up (1 = not healthy).
52+ verify_live () {
53+ _i=1
54+ while [ " $_i " -le 15 ]; do
55+ if sh " $SMOKE " " http://127.0.0.1:4000/api" >> " $LOG " 2>&1 ; then return 0; fi
56+ sleep 2; _i=$(( _i + 1 ))
57+ done
58+ return 1
59+ }
60+ cleanup () { [ -n " ${TESTPID:- } " ] && kill " $TESTPID " 2> /dev/null || true ; purge_staging; rmdir " $LOCK " 2> /dev/null || true ; }
3861fail () { log " FAIL: $* " ; set_status " error" ; cleanup; exit 1; }
3962
4063# Single-flight: mkdir is atomic.
@@ -55,9 +78,14 @@ git -C "$CLONE_DIR" reset --hard "origin/$BRANCH" >>"$LOG" 2>&1 || fail "git res
5578
5679# 2) build in staging. Reuse live node_modules to speed the install; build fresh dist.
5780log " staging build"
58- rm -rf " $STAGE " ; mkdir -p " $STAGE " || fail " mkdir staging"
81+ purge_staging ; mkdir -p " $STAGE " || fail " mkdir staging"
5982( cd " $SRC " && tar --exclude=node_modules --exclude=dist -cf - . ) | ( cd " $STAGE " && tar -xf - ) || fail " copy source to staging"
60- [ -d " $LIVE /node_modules" ] && cp -a " $LIVE /node_modules" " $STAGE /node_modules"
83+ # Warm the install from the live node_modules to speed `yarn install`. Use tar (not cp -a) and EXCLUDE
84+ # proot's ".l2s." loop artifacts so the copy never trips on them (cp -a would ELOOP) and staging stays
85+ # cleanly removable; yarn reconciles anything missing.
86+ if [ -d " $LIVE /node_modules" ]; then
87+ ( cd " $LIVE " && tar --exclude=' *.l2s.*' -cf - node_modules ) | ( cd " $STAGE " && tar -xf - ) || true
88+ fi
6189( cd " $STAGE " && yarn install >> " $LOG " 2>&1 && yarn build >> " $LOG " 2>&1 ) || fail " yarn install/build (offline or build error) — live untouched"
6290[ -f " $STAGE /dist/server.js" ] || fail " no dist/server.js after build — live untouched"
6391
@@ -71,43 +99,51 @@ RC=$?
7199kill " $TESTPID " 2> /dev/null || true ; wait " $TESTPID " 2> /dev/null || true ; TESTPID=" "
72100[ " $RC " -eq 0 ] || fail " staged smoke test failed (rc=$RC ) — NOT promoting; live untouched"
73101
74- # 4) promote: back up live dist, swap staged in, sync source + nginx, restart.
102+ # 4) promote — swap the dist FIRST and verify it live; only advance source + package.json (and the
103+ # nginx vhost) AFTER the live check passes. That way a failed verify rolls back the dist and the
104+ # version/source were NEVER touched, so the box can't report a version it isn't actually running.
75105log " staged build passed — promoting"
76106rm -rf " $BACKUP "
77107[ -d " $LIVE /dist" ] && cp -a " $LIVE /dist" " $BACKUP "
78- # Sync source (so the next build matches) — additive tar after dropping pure-source subdirs;
79- # runtime state (node_modules, *.sqlite3 job storage, books/catalog.db) is left in place.
80- for d in sockets views public test ; do rm -rf " $LIVE /$d " ; done
81- ( cd " $STAGE " && tar --exclude=node_modules --exclude=dist -cf - . ) | ( cd " $LIVE " && tar -xf - ) || fail " sync source to live"
82- cp -a " $STAGE /node_modules/." " $LIVE /node_modules/" 2> /dev/null || true
108+ # node_modules is additive (new deps added, old ones remain), so the old dist tolerates it on rollback;
109+ # safe to sync before the swap so the new dist has its dependencies. tar+exclude avoids the ".l2s." loops.
110+ ( cd " $STAGE " && tar --exclude=' *.l2s.*' -cf - node_modules ) | ( cd " $LIVE " && tar -xf - ) || true
83111# The dist swap is the near-atomic, restart-critical step (dash-node runs dist/server.js).
84112rm -rf " $LIVE /dist" && cp -a " $STAGE /dist" " $LIVE /dist" || fail " dist swap"
85- # nginx reads /etc/nginx/conf.d, not /library/dashboard, so mirror the vhost.
86- [ -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" ; }
87113
88- log " restart dash-node + nginx "
114+ log " restart dash-node"
89115/usr/local/bin/pdsm restart dash-node >> " $LOG " 2>&1 || log " warn: pdsm restart dash-node returned non-zero"
90- /usr/local/bin/pdsm restart nginx >> " $LOG " 2>&1 || log " warn: pdsm restart nginx returned non-zero"
91116
92- # 5) verify LIVE; roll back the dist if it doesn't come up .
117+ # 5) verify LIVE. On success, finalize (source + package.json + nginx); on failure, roll the dist back .
93118log " verifying live :4000"
94- ok=0
95- i=1
96- while [ " $i " -le 15 ]; do
97- if sh " $SMOKE " " http://127.0.0.1:4000/api" >> " $LOG " 2>&1 ; then ok=1; break ; fi
98- sleep 2; i=$(( i + 1 ))
99- done
100- if [ " $ok " -eq 1 ]; then
101- log " live OK — rebuild complete"
119+ if verify_live; then
120+ log " live OK — finalizing (source + package.json + nginx)"
121+ # Now safe to advance the source so the reported version + the next build match the running dist.
122+ # Additive tar after dropping the pure-source subdirs; runtime state (node_modules, *.sqlite3 job
123+ # storage, books/catalog.db) is left in place.
124+ for d in sockets views public test ; do rm -rf " $LIVE /$d " ; done
125+ ( cd " $STAGE " && tar --exclude=node_modules --exclude=dist -cf - . ) | ( cd " $LIVE " && tar -xf - ) || log " warn: source sync incomplete"
126+ # nginx reads /etc/nginx/conf.d, not /library/dashboard, so mirror the vhost then reload nginx.
127+ [ -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" ; }
128+ /usr/local/bin/pdsm restart nginx >> " $LOG " 2>&1 || log " warn: pdsm restart nginx returned non-zero"
129+ log " rebuild complete"
102130 rm -rf " $BACKUP "
103131 set_status " done"
104132else
105133 log " live check FAILED after swap — rolling back dist"
106134 if [ -d " $BACKUP " ]; then
107135 rm -rf " $LIVE /dist" && cp -a " $BACKUP " " $LIVE /dist"
108136 /usr/local/bin/pdsm restart dash-node >> " $LOG " 2>&1 || true
109- log " rolled back to previous dist"
137+ # Confirm the rolled-back build actually recovered — don't just assume it did.
138+ if verify_live; then
139+ log " rolled back — verified healthy on the previous build (source + version were never advanced)"
140+ else
141+ log " ROLLBACK VERIFY FAILED — dashboard may be degraded; manual check needed"
142+ fi
143+ else
144+ log " no backup available to roll back to — dashboard may be degraded"
110145 fi
146+ rm -rf " $BACKUP "
111147 set_status " error"
112148fi
113149cleanup
0 commit comments