#!/usr/bin/env bash # # Liveness tick for a deployed service. # # ## Why this exists # # The five-minute healthcheck it replaced was prose handed to a model: "GET # /healthz. Do NOT use /api/internal/v1/healthz." It duly reported a 404 # on `/api/internal/v1/health` — a third path, neither the one it was told to # use nor the one it was told to avoid, and one that had never existed. The site # was healthy throughout. # # An explicit prohibition constrained one wrong URL and left every other wrong # URL open, because the address was being re-derived on every run rather than # read. So it is written here once, as a string in version control, and the # whole class of failure goes with it. # # That failure is the expensive kind. The job's own state read `lastRunStatus: # ok, consecutiveErrors: 0` while a red alert went to a DM — so the monitor was # reporting itself healthy and crying wolf at the same time, and a monitor # nobody believes is a monitor nobody has. # # ## It holds no credential, and that is the point # # `/healthz` is unauthenticated by design — `SECURITY_CHECKLIST.md` makes it a # checklist item, and the container's own HEALTHCHECK uses it. So unlike # `reconcile.sh` and `analyze.sh`, this script reads no token, sources no env # file, and has nothing to leak. At 288 runs a day that is worth more than the # extra assurance an authenticated probe would buy. # # The authenticated sibling, `/api/internal/v1/agent/health`, reports more and # needs a token. It is deliberately not used here: this asks "is the site up", # which is a question with a public answer. # # ## Both halves are checked # # 503 is a real answer, not an outage — `/api/health` returns it when the # `SELECT 1` against SQLite fails, so the container is marked unhealthy while # the marketing pages keep serving perfectly well. Status alone is therefore # not a verdict; the check asserts HTTP 200 *and* `"status":"ok"` *and* # `"db":"ok"`. # # ## The path, written down rather than re-derived # # This project's endpoint is `/api/health`. It is NOT `/healthz`, which is what # the template's copy of this script probed and what a plausible guess produces. # The whole argument above is about exactly this line, so it is stated once, # here, and nowhere else. # # ## Installing it # # install -m 0755 healthcheck.sh ~/bin/healthcheck.sh # # then, in the crontab — every five minutes # */5 * * * * $HOME/bin/healthcheck.sh \ # >> $HOME/.healthcheck.log 2>&1 # # On this deployment it is run by an OpenClaw cron job instead, which is the # same thing with a scheduler that can also deliver the alert. set -euo pipefail # `-`, not `:-`. Unset means "no opinion, use production". Set-and-empty means # a config is wrong, and substituting production for it would report the health # of a site nobody asked about — quietly, and only where somebody was trying to # point this somewhere else. # The PRODUCTION origin — the one customers use. qn.isnull.dev is the same # container through a different ingress, and is worth checking separately when # you are trying to tell the app apart from what is in front of it: # # HEALTHCHECK_BASE_URL=https://qn.isnull.dev bash scripts/healthcheck.sh BASE_URL="${HEALTHCHECK_BASE_URL:-https://queuenorth.com}" TIMEOUT="${PRIVACY_TIMEOUT:-15}" stamp() { date -Is; } say() { printf '%s healthcheck: %s\n' "$(stamp)" "$*"; } if [ -z "$BASE_URL" ]; then say "FAIL no base URL. HEALTHCHECK_BASE_URL is set but empty; unset it for the default." exit 78 # EX_CONFIG fi body=$(mktemp) trap 'rm -f "$body"' EXIT # Assigned in the `if`, not with `|| echo "000"` appended. On a connection # failure curl *already* prints "000" via --write-out and then exits non-zero, # so appending a fallback produces "000000", which matches no branch below and # reports "unexpected HTTP" for the one failure this script names explicitly. # # No -v and no --trace, ever. This request carries no credential, but the habit # is the rule SECURITY.md states: a request URL or header must not reach a log. if ! status=$( curl --silent --show-error --output "$body" --write-out '%{http_code}' \ --max-time "$TIMEOUT" \ "$BASE_URL/api/health" ); then status="000" fi case "$status" in 200) # The status got us here; the body decides. Both fields are asserted rather # than assumed because a 200 from Cloudflare, a cached page or an error page # is still a 200, and none of them are this application answering. # # Both, not either: the app can answer `"status":"ok"` while its database # handle is gone, and that is the state where the site looks fine and every # form submission is being lost. if grep -q '"status":"ok"' "$body" && grep -q '"db":"ok"' "$body"; then say "ok" elif grep -q '"status":"ok"' "$body"; then say "FAIL 200 and the app is up, but the database is not answering." say " Every form submission is failing. See docs/OPERATIONS.md." exit 70 # EX_SOFTWARE else say "FAIL 200 but not this application. The response carried neither" say " \"status\":\"ok\" nor a recognisable health body — check whether" say " something in front of the origin answered instead." exit 70 # EX_SOFTWARE fi ;; 503) # Named rather than left to the catch-all, because it is the one unhealthy # answer this route is designed to give: migrations failed, the container is # marked unhealthy, and the public pages are still being served. That is a # different thing from the site being down and reads differently at 3am. say "FAIL the database is not reachable (HTTP 503). The site is serving" say " static pages; every lead and support submission is being lost." exit 70 # EX_SOFTWARE ;; 000) say "FAIL could not reach $BASE_URL" exit 69 # EX_UNAVAILABLE ;; *) # Includes 404. If this ever fires on a path this script wrote itself, the # route moved — which is a thing to fix in one place rather than a thing for # a caller to guess around. say "FAIL unexpected HTTP $status from $BASE_URL/api/health" exit 1 ;; esac