Skip to content

feat: website health check cron (standalone GH Actions workflow)#39

Merged
ItsThompson merged 4 commits into
mainfrom
feat/website-healthcheck-cron
Jul 18, 2026
Merged

feat: website health check cron (standalone GH Actions workflow)#39
ItsThompson merged 4 commits into
mainfrom
feat/website-healthcheck-cron

Conversation

@ItsThompson

Copy link
Copy Markdown
Owner

Summary

Adds a standalone GitHub Actions workflow that health-checks the production site (https://usegofin.com) every 12 hours, verifying the public edge and the backend are up. On failure the run goes red and posts a Discord alert; success is silent.

"Up" is proven end-to-end by a single probe: Cloudflare → tunnel → shell → gateway → all 4 downstream services (auth, expense, finance, datarights).

What's included

  • GET /readyz (gateway) — new public readiness aggregate that fans out concurrently to each downstream service's shallow /health, bounded by a 2s per-probe timeout. Returns 200 {"status":"ok"} only when all four are healthy, else 503 naming each service's state. Kept separate from the shallow /health (Docker HEALTHCHECK) to avoid coupling container liveness to downstream availability.
  • GET /healthz (shell) — new public liveness endpoint that mirrors the gateway's /readyz (relaying its JSON body so CI logs name the failing service). Bounded by AbortSignal.timeout so it never hangs. Mounted after the /api proxy and before the SSR catch-all.
  • .github/workflows/healthcheck.yml — standalone cron (0 */12 * * *) + workflow_dispatch, independent of ci.yml/cd.yml. Curls the header-gated /healthz with the X-Health-Token secret, retries 3× to absorb transient blips, fails the job on non-200/unreachable, and posts exactly one Discord message on failure only.

The existing shallow /health endpoints (gateway + per-service) are unchanged. Docs (docs/api.md, docs/monitoring.md) are deferred/out of scope per the plan.

How it gets past Cloudflare Under Attack Mode

A pre-provisioned Cloudflare WAF Skip rule bypasses Under Attack Mode for path == /healthz AND header X-Health-Token == <token>. Only this probe (with the secret header) can reach the origin; a normal automated client still gets a 403 challenge. The rule expression is documented at the top of the workflow (never the token value).

Testing

  • Gateway: go test ./... (all packages pass), go vet clean, golangci-lint 0 issues. New tests cover all-healthy, one-down (names the service), unreachable, hung/timeout (bounded), genuine concurrency, and the Handler 200/503 + router /readyz cases.
  • Shell: vitest 13 files / 116 tests, server/healthz.ts at 100% coverage, coverage thresholds pass, eslint clean. The vitest.config.ts include was extended so the new server/** test is actually discovered (not silently skipped).
  • Workflow: actionlint (incl. shellcheck) 0 issues; happy/failure paths and the Discord JSON payload verified locally against a stub server.

Open dependencies (operator actions, not code)

  • DISCORD_WEBHOOK_URL must be added as a repo-level GitHub Actions secret (today it only exists as an Alertmanager env var). Until then the failure path still reddens the job, but the Discord POST would error.
  • Deploy ordering: /healthz + /readyz must ship to prod via cd.yml on merge to main before the first live cron run can pass. workflow_dispatch's happy path is therefore not verifiable pre-merge; post-deploy smoke (dispatch → green/no Discord; forced bad token → red + one Discord) remains an operator step.

Note

HEALTHCHECK_TOKEN and the Cloudflare WAF Skip rule were provisioned during planning and are not touched here.

Add a public, gateway-native GET /readyz that fans out concurrently to
each downstream service's shallow /health (auth, expense, finance,
datarights), bounded by a per-probe timeout. It returns 200
{"status":"ok"} only when all four are healthy, else 503 naming each
service's state, so a single probe proves the whole backend is reachable.

Kept separate from the shallow /health (Docker HEALTHCHECK) to avoid
coupling container liveness to downstream availability. Classified Public
via GatewayResolve alongside /health and /metrics.
Mount a public GET /healthz on the shell that mirrors the gateway's
/readyz aggregate: 200 when the gateway reports all backend services
healthy, else 503, relaying the gateway JSON body so CI logs name the
failing service. A hung/unreachable gateway is bounded by
AbortSignal.timeout so the endpoint never hangs. The handler is a DI
factory (gatewayUrl, fetchFn, timeoutMs) so it is unit-testable without
booting SSR, and is mounted after the /api proxy and before the SSR
catch-all.

Extend the shell vitest include glob with server/**/__tests__/** so the
new server test is discovered and run (previously app/**-only globs would
silently skip it).
Add .github/workflows/healthcheck.yml, independent of ci.yml/cd.yml. On a
12h schedule (and workflow_dispatch) it curls the public, header-gated
https://usegofin.com/healthz with the X-Health-Token secret header,
retries a bounded 3x to avoid false alarms from transient blips, and
fails the job on non-200/unreachable. Only on failure it posts exactly
one Discord alert (repo, last status, relayed body, run URL) via jq-built
JSON to the DISCORD_WEBHOOK_URL secret; success is silent.

A top-of-file comment records the Cloudflare WAF Skip rule expression
(path + X-Health-Token, not the token value) so an operator can
reconstruct the edge gate from a repo artifact.
Address review: /readyz previously used a second hardcoded service-name
map (servicesFromURLs) parallel to the proxies map, a latent drift hazard
(a 5th service added to proxies but omitted here would make /readyz
silently skip it). Introduce serviceURLMap as the single canonical
name->URL source; build both the reverse-proxy handlers and the readiness
fan-out from it, so the existing prefix->proxy panic guard now also
guarantees /readyz probes every proxied service by construction.

Also drain probe response bodies before Close (keep-alive reuse) and make
the timeout test's stub honour request-context cancellation so t.Cleanup
no longer blocks on the full 2s sleep (~2s -> 0.1s).
@ItsThompson
ItsThompson merged commit 452051b into main Jul 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant