#!/usr/bin/env bash # Local development server: start|stop|status|restart|logs|bundles # # ./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" # Phase 24: where the stored agent bundles are materialised for `install-agent.sh` to hand to a # host. Never committed — a bundle carries that host's private key — because `data/` is # gitignored, and the CA itself stays in the database (`certs.py`), never in this directory. CERTDIR="$ROOT/data/certs" mkdir -p "$ROOT/data/logs" running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; } # The CA is durable in the database, so "generate certs during dev" means *export what the first # boot minted*: one .bundle per configured host in gitignored data/certs/, ready to `scp` # to the host it belongs to. The names come out of HOST_TARGETS exactly the way `parse_targets` # reads it (space or comma separated `name=url`), so this script invents no host of its own and # re-parses nothing inside the exporter. # # A failure here is a warning line, never a start failure: the readiness probe proved the server # is up, and a bundle export that cannot sign in costs a dev convenience — not the dashboard, and # not the authority (the exporter only ever GETs the bundle route; it cannot mint). export_bundles() { local port="$1" names=() entry targets="${HOST_TARGETS:-}" for entry in ${targets//,/ }; do # space or comma separated, like config.parse_targets names+=("${entry%%=*}") done if [ ${#names[@]} -eq 0 ]; then echo " - no bundles exported (HOST_TARGETS unset)" return 0 fi .venv/bin/python scripts/dev-certs-export.py \ --url "http://127.0.0.1:${port}" \ --password "${DASH_ADMIN_PASSWORD:-}" \ --names "${names[@]}" \ --out-dir "$CERTDIR" \ || echo " ! bundle export failed — check DASH_ADMIN_PASSWORD in .env.dev (the server is up and the CA is untouched)" } # The exported-bundles block: names, sizes and modes, straight off the disk. `status` prints it, # because "is this host's bundle there yet?" is the question `status` gets asked; `bundles` is # the same block on its own. bundles() { echo "bundles exported ($CERTDIR):" local f found=0 for f in "$CERTDIR"/*.bundle; do [ -e "$f" ] || continue found=1 printf ' %s %s bytes mode 0%s\n' "${f##*/}" "$(wc -c <"$f")" "$(stat -c %a "$f")" done [ "$found" -eq 1 ] || echo " (none — restart the server, or run scripts/dev-certs-export.py)" } 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" export_bundles "$PORT" 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 bundles ;; bundles) bundles ;; logs) tail -n 40 "$LOG" ;; *) echo "usage: $0 start|stop|restart|status|logs|bundles"; exit 2 ;; esac