Files
ducoterra 89d9fb9f89 phase: 24_dev_mtls_rollout
**Phase 24 final verification — all green**

- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
2026-10-21 13:47:45 -04:00

134 lines
5.2 KiB
Bash
Executable File

#!/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 <name>.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