Files
dashboards/scripts/dev-server.sh
T
ducoterra 1734d81e48 phase: 11_admin_login
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.

**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).

**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.

**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.

**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.

**Next pending phase**: `12_nut_self_test_verdict`.
2026-10-19 07:45:44 -04:00

87 lines
2.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Local development server: start|stop|status|restart|logs
#
# ./scripts/dev-server.sh restart # reads .env.dev if present, runs on DASH_PORT
#
# The server keeps running after this shell exits (nohup + pidfile) so you can browse it
# while an agent works. Logs: data/logs/dev.log
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
PIDFILE="$ROOT/data/dev-server.pid"
LOG="$ROOT/data/logs/dev.log"
mkdir -p "$ROOT/data/logs"
running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }
start() {
if running; then
echo "already running (pid $(cat "$PIDFILE")) — http://127.0.0.1:${DASH_PORT:-8090}"
return 0
fi
if [ -f "$ROOT/.env.dev" ]; then
set -a; . "$ROOT/.env.dev"; set +a
echo "loaded .env.dev"
fi
PORT="${DASH_PORT:-8090}"
nohup .venv/bin/dashboards >"$LOG" 2>&1 &
echo $! >"$PIDFILE"
sleep 3
# The readiness probe is /api/health, which stayed public in phase 11: this script needs no
# credential and must never grow one. What it can say is where the credential comes from.
if running && curl -sf -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then
echo "started pid $(cat "$PIDFILE") — http://127.0.0.1:${PORT}"
echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"
else
echo "FAILED to start; last lines of $LOG:"
tail -20 "$LOG"
return 1
fi
}
stop() {
if running; then
local pid
pid="$(cat "$PIDFILE")"
kill "$pid" 2>/dev/null
# Wait for the process to actually go away before handing the pidfile back. A bare
# `sleep 1` was not enough: uvicorn's graceful shutdown waits for in-flight requests,
# so `restart` with a browser open started the new server while the old one still
# held the SQLite file — the dying process then logged `database is locked` for a poll
# it had already fetched, which the app correctly turned into a red store-error event.
# Two servers on one DB is the one way this script can fabricate an incident, so the
# wait is bounded rather than optimistic, and SIGKILL is the last resort.
local i=0
while kill -0 "$pid" 2>/dev/null; do
i=$((i + 1))
if [ "$i" -gt 60 ]; then
echo "pid $pid ignored SIGTERM after 20s; sending SIGKILL"
kill -9 "$pid" 2>/dev/null
break
fi
sleep 0.25
done
echo "stopped"
else
echo "not running"
fi
rm -f "$PIDFILE"
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status)
if running; then
echo "running pid $(cat "$PIDFILE")"
curl -s "http://127.0.0.1:${DASH_PORT:-8090}/api/health" \
| .venv/bin/python -c "import json,sys; d=json.load(sys.stdin); print('status:', d['status']); [print(' ', s['key'], 'ok' if s['ok'] else 'DOWN', (s['error'] or '')[:90]) for s in d['sources']]"
else
echo "not running"
fi ;;
logs) tail -n 40 "$LOG" ;;
*) echo "usage: $0 start|stop|restart|status|logs"; exit 2 ;;
esac