start.sh auto-sync dashboard bundle into /var/www/dashcaddy-status/
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

After every rebuild the freshly-baked dashboard bundle lives in
/opt/dashcaddy/status/dist/ + sw.js. DNS2 also serves files from
/var/www/dashcaddy-status/dist/ + sw.js (the original Windows-installer
mirror path). Without an explicit copy step between build and start.sh,
the served bundle stays on whatever hash was there before, while the API
responds with new code. That mismatch is what shows up in the dashboard
as "version unavailable" + "no data" widgets — saw it in the deploy
that followed DC-046/047 (fixed by a manual cp this time, never again).

Sync block runs before docker run:
  cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/
  cp /opt/dashcaddy/status/sw.js   /var/www/dashcaddy-status/
  cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/

All  guarded so set -e doesn't kill the container start on a
single per-file failure (e.g. read-only mount, missing dir). Missing
source dir is a WARN + no-op rather than a fatal — fresh installs
without status/dist/ don't get a stale-bundle problem, just a log line.

Test: scripts/test-start-sh-sync.sh — 7 assertions across 4 cases
(fresh-copy, missing-source, idempotent-re-sync, set-e-survives-permission-
denied). All pass.
This commit is contained in:
Hermes Agent
2026-07-20 02:07:25 -07:00
parent c619d3a36b
commit 56f1a001f2
2 changed files with 171 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
# Regression test: start.sh dashboard bundle sync step.
#
# Validates the auto-sync that runs before `docker run` in start.sh:
# 1. Bundles are coppied from /opt/dashcaddy/status/dist → /var/www/dashcaddy-status/dist
# 2. sw.js is copied to /var/www/dashcaddy-status/sw.js
# 3. index.html is copied to /var/www/dashcaddy-status/index.html
# 4. Missing source dir is handled gracefully (WARN, no exit)
# 5. Sync is idempotent — re-running doesn't clobber or duplicate
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FAILURES=0
pass() { echo "$1"; }
fail() { echo "$1"; FAILURES=$((FAILURES + 1)); }
# --------------------------------------------------------------------------
# Extract just the sync block from start.sh. The first call to `docker run`
# marks the end of the sync section.
# --------------------------------------------------------------------------
extract_sync_block() {
awk '
/^# Sync the freshly-built dashboard bundle/ { capture=1 }
/^docker run -d --restart unless-stopped/ { capture=0 }
capture { print }
' "${SCRIPT_DIR}/../start.sh"
}
fresh_workdir() {
local d; d="$(mktemp -d /tmp/dashcaddy-sync-test.XXXXXX)"
mkdir -p "$d/src/dist"
mkdir -p "$d/dst/dist"
echo "$d"
}
cleanup() { rm -rf "$1"; }
run_sync() {
local workdir="$1"
# Use absolute replacement paths that don't collide with sed prefixes.
# Inject a unique sentinel into start.sh paths first, then replace that
# sentinel with the test-specific paths.
local src_sentinel="__SYNC_TEST_SRC__"
local dst_sentinel="__SYNC_TEST_DST__"
local SRC_DIST_DIR="${workdir}/src/dist"
local DST_STATUS_DIR="${workdir}/dst"
extract_sync_block | sed \
-e "s|/opt/dashcaddy/status/dist|${src_sentinel}|g" \
-e "s|/var/www/dashcaddy-status|${dst_sentinel}|g" \
-e "s|${src_sentinel}|${SRC_DIST_DIR}|g" \
-e "s|${dst_sentinel}|${DST_STATUS_DIR}|g" \
> /tmp/_sync_block.sh
# shellcheck disable=SC1091
source /tmp/_sync_block.sh
}
# Test 1: Bundles + sw.js + index.html all get synced from a real source tree
echo "Test 1: fresh source → files synced to status dir"
WORKDIR="$(fresh_workdir)"
SRC="${WORKDIR}/src/dist"
echo 'console.log("core");' > "${SRC}/core.js"
echo 'console.log("features");' > "${SRC}/features.js"
echo 'self.addEventListener("fetch", ...)' > "${SRC}/sw.js"
echo '<html><body>hi</body></html>' > "${SRC}/index.html"
run_sync "${WORKDIR}"
if [ -f "${WORKDIR}/dst/dist/core.js" ]; then
pass "core.js copied to status dir"
else
fail "core.js NOT copied"
fi
if [ -f "${WORKDIR}/dst/dist/features.js" ]; then
pass "features.js copied"
else
fail "features.js NOT copied"
fi
if [ -f "${WORKDIR}/dst/sw.js" ]; then
pass "sw.js copied (NOT under dist/)"
else
fail "sw.js NOT copied"
fi
if [ -f "${WORKDIR}/dst/index.html" ]; then
pass "index.html copied"
else
fail "index.html NOT copied"
fi
cleanup "${WORKDIR}"
# Test 2: Missing source dir → WARN, no exit, no crash, no destinations created
echo
echo "Test 2: source dir missing → WARN, no exit"
WORKDIR="$(fresh_workdir)"
# SRC intentionally NOT created
run_sync "${WORKDIR}"
# If we reach here without an exit code propagating, we passed
if [ -z "$(ls -A "${WORKDIR}/dst/dist" 2>/dev/null)" ]; then
pass "no files copied when source missing"
else
fail "files copied when source was missing — should be no-op"
fi
cleanup "${WORKDIR}"
# Test 3: Re-running syncs (idempotent — files overwritten with same content)
echo
echo "Test 3: idempotency (re-sync overwrites with same content)"
WORKDIR="$(fresh_workdir)"
SRC="${WORKDIR}/src/dist"
echo 'core-v1' > "${SRC}/core.js"
run_sync "${WORKDIR}"
echo 'core-v2' > "${SRC}/core.js" # mutate source
run_sync "${WORKDIR}"
if [ "$(cat "${WORKDIR}/dst/dist/core.js")" = "core-v2" ]; then
pass "second sync picked up the latest source"
else
fail "second sync did not update"
fi
cleanup "${WORKDIR}"
# Test 4: set -e propagation — a single file-level failure doesn't take down the script
echo
echo "Test 4: set -e does not kill container start when sync hits a permission error"
WORKDIR="$(fresh_workdir)"
SRC="${WORKDIR}/src/dist"
echo 'core' > "${SRC}/core.js"
DST="${WORKDIR}/dst/dist"
chmod 555 "${DST}" # write-protected dest → cp -f would fail
# shellcheck disable=SC1091
set -e
run_sync "${WORKDIR}"
EXIT=$?
set +e
chmod 755 "${DST}" 2>/dev/null
cleanup "${WORKDIR}"
if [ "$EXIT" -eq 0 ]; then
pass "sync step survives a permission-denied dest"
else
fail "set -e propagated a sync failure (exit ${EXIT}); would prevent container from starting"
fi
rm -f /tmp/_sync_block.sh
echo
if [ "$FAILURES" -eq 0 ]; then
echo "All sync regression tests passed."
exit 0
fi
echo "${FAILURES} test(s) failed."
exit 1
+20
View File
@@ -115,6 +115,26 @@ fi
# that the local tailscaled handles; we never need to mutate state # that the local tailscaled handles; we never need to mutate state
# from inside the container. # from inside the container.
echo "[start.sh] Creating container with full config..." echo "[start.sh] Creating container with full config..."
# Sync the freshly-built dashboard bundle into the static directory Caddy
# serves. The Docker image bakes dist/ from the source tree at build time,
# but DNS2 also serves /var/www/dashcaddy-status/dist/ (the original
# Windows installer mirror path). If we don't sync after every build, the
# served bundle keeps the OLD hash while the API responds with new code,
# which shows up in the dashboard as "version unavailable" + "no data"
# widgets because the new API surface doesn't match the old widget code.
# This step is idempotent and ~50ms — always safe to run.
echo "[start.sh] Syncing dashboard bundle into static dir..."
mkdir -p /var/www/dashcaddy-status/dist
if [ -d /opt/dashcaddy/status/dist ]; then
cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/ 2>/dev/null || true
cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/ 2>/dev/null || true
cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/ 2>/dev/null || true
echo "[start.sh] Bundle synced ($(ls /opt/dashcaddy/status/dist/*.js 2>/dev/null | wc -l) bundle files + sw.js + index.html)."
else
echo "[start.sh] WARN: /opt/dashcaddy/status/dist missing — skipping sync (frontend will be stale)."
fi
docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
--add-host=get.dashcaddy.net:194.233.88.206 \ --add-host=get.dashcaddy.net:194.233.88.206 \
--add-host=get2.dashcaddy.net:194.233.88.206 \ --add-host=get2.dashcaddy.net:194.233.88.206 \