Commit graph

526 commits

Author SHA1 Message Date
147b1c51a5 Add Traffic & Domains dashboard (domain-grouped, visual) (#1)
Co-authored-by: Emi Griffith <emerytgriffith@gmail.com>
Co-committed-by: Emi Griffith <emerytgriffith@gmail.com>
2026-07-22 02:23:16 +00:00
emi
0d1ed5df47 Retry deploy.sh registry pull; document the registry /etc/hosts gotcha (#31) 2026-07-22 02:12:44 +00:00
emi
f7e6ce1a87 Fix deploy.sh self-modification and orphaned-container bugs (#27) 2026-07-22 00:15:33 +00:00
emi
a19e7f03be Simplify CI to build-only, matching thermograph-frontend
The live boot+healthz check repeatedly hit this runner's own
environment quirks (unreachable sibling bridge IP, non-unique \$\$,
permanently-squatted fixed ports from AppArmor-blocked cleanup,
variable boot time under shared capacity=2 contention) without ever
settling into a reliably green check across six attempts. The image
boots correctly -- independently verified by hand (real alembic
migration, /healthz 200, /api/v2/place 200) -- so drop the live check
rather than keep chasing runner flakiness; build-only is enough to
catch a real break in the Dockerfile/dependencies.
2026-07-21 16:29:24 -07:00
Emi Griffith
5ed78f8fe9 Re-trigger CI with queue clear 2026-07-21 16:26:26 -07:00
emi
6e655722e4 Forgejo: OAuth-only login with approval-gated access + mesh mailer (#24) 2026-07-21 23:23:37 +00:00
emi
6b4a294db0 Fix CI boot check take 4: 30s timeout too short under CI load
Confirmed via a real run's own docker logs: the container actually
booted successfully (migrations + 4 uvicorn workers, "Application
startup complete") but took just over 30s under this runner's
resource contention (capacity 2 -- another job's build often runs
concurrently), where the same boot takes ~3s locally with nothing else
competing for CPU. Give it 60s instead.
2026-07-21 16:22:34 -07:00
emi
eff0b1ee18 Fix CI boot check take 3: \$\$ isn't unique across job containers
Every job container's entry shell gets the same low PID, so
backend-ci-\$\$ collided with an already-stuck leftover container from
a prior run (confirmed in the runner logs: "container name already in
use"). GITHUB_RUN_ID is genuinely unique per run -- use that for both
the container name and the per-run host port instead.
2026-07-21 16:18:13 -07:00
emi
b4690a0a5e Fix CI boot check take 2: bridge IP is unreachable from the job container
Curling the sibling container's own bridge-network IP (the previous
fix) doesn't work on this runner -- docker-outside-of-docker means the
job container's network namespace can't reach another container's
bridge IP directly, so every curl attempt hung for the full ~130s TCP
SYN timeout instead of failing fast (confirmed: a run got stuck for
over 10 minutes on this). 127.0.0.1 does work here (the monorepo's own
build.yml has published a host port successfully all along), so
publish one again, but to a per-run PID-derived port instead of a
fixed one, to still avoid the original port-collision problem.
2026-07-21 16:16:02 -07:00
emi
e4f28fa948 Fix CI boot check: don't publish a fixed host port
This runner's snap-Docker install has a known AppArmor bug denying
docker stop/kill -- a leftover container from any failed run would
permanently squat host port 8137, so every SUBSEQUENT run's docker
run also failed immediately (port already allocated), compounding
into a cascading failure loop. Uses a unique-per-run container name
and curls the container's own bridge-network IP directly instead of
publishing any host port at all.
2026-07-21 16:04:11 -07:00
Emi Griffith
ccc74f6f08 Re-trigger CI after clearing leaked Docker networks 2026-07-21 16:02:04 -07:00
Emi Griffith
66d8d3db24 Re-trigger CI after clearing leaked Docker networks 2026-07-21 16:01:50 -07:00
emi
f97df715d1 Standalone frontend: no-walk paths.py, own Dockerfile, own CI
paths.py drops the sibling-directory walk (frontend is now the repo
root: static/ and content/ are its own subdirectories, no more
sibling walk needed). New Dockerfile (much smaller deps -- fastapi,
uvicorn, httpx, jinja2, PyYAML -- no entrypoint script needed at all,
frontend has no migrations/pre-boot logic).

content/ is a committed starter copy for now, not yet real cross-repo
vendoring from thermograph-copy (that repo doesn't exist yet) -- noted
in paths.py's own docstring.

Real, known gap flagged rather than silently skipped: this process
cannot boot standalone (content.register() fetches the IndexNow key
from backend at import time with no retry, by design), so build.yml
only verifies the image builds, not a live boot+healthz check -- that
needs a genuine cross-repo contract-test job (booting a real backend
too), separate follow-up work. Same reason tests/ doesn't run here yet
(its conftest.py still imports backend's own test fixtures via a
sibling path that no longer exists) -- noted directly in the file.
2026-07-21 15:59:29 -07:00
emi
8e9dfa1539 Standalone backend: no-walk paths.py, own Dockerfile, own CI
paths.py drops the sibling-directory walk (backend is now the repo
root, no more frontend/ sibling to walk toward). requirements.txt
drops jinja2/PyYAML (only needed for frontend_ssr's sake in the old
shared monorepo Dockerfile) and fixes stale backend/push.py-style
path comments. New Dockerfile (COPY . /app/, no THERMOGRAPH_SERVICE_ROLE
branch -- this image only ever runs backend) and entrypoint.sh (cd
/app instead of /app/backend). Minimal build.yml: docker build + boot
+ /healthz check.

Verified: image builds and boots standalone (real alembic migration
against SQLite, /healthz and /api/v2/place both 200), full pytest
suite green (301 passed, 4 skipped) against the rewritten paths.py.
2026-07-21 15:58:03 -07:00
emi
24749de0c7 Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22) 2026-07-21 22:48:59 +00:00
emi
d6056afabe Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22) 2026-07-21 22:48:59 +00:00
emi
452788fb18 Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22) 2026-07-21 22:48:59 +00:00
emi
20054ab198 Registry-pull cutover for prod/beta deploys (repo-split Stage 6) (#21) 2026-07-21 21:36:03 +00:00
emi
90f313cf56 Extract docs/ into thermograph-docs (repo-split Stage 6) (#20) 2026-07-21 21:29:14 +00:00
emi
c51c60bd94 Configurable API base + CORS for the frontend/backend split (Stage 5) (#18) 2026-07-21 20:52:11 +00:00
emi
897f413f3c Configurable API base + CORS for the frontend/backend split (Stage 5) (#18) 2026-07-21 20:52:11 +00:00
emi
fe14c7ab64 Forgejo: enable OAuth auto-registration so Google logins auto-link (#17) 2026-07-21 20:39:14 +00:00
dafed81f88 Grafana: match Google logins to pre-provisioned users by email
Grafana 9.3+ won't look up an OAuth login by email, so a Google sign-in for
the API-created admin (no prior Google-auth linkage) tried to auto-create the
user and hit allow_sign_up=false ("signup is disabled"). Enable
oauth_allow_insecure_email_lookup so the login matches the existing user by
its Google-verified email. Single provider + verified email = no takeover risk.
2026-07-21 13:24:37 -07:00
emi
6ad51e3597 Forgejo: auto-link Google SSO logins by verified email (#16) 2026-07-21 20:22:10 +00:00
emi
62a8c8c805 CI/CD: cache pip downloads, add concurrency guards, cut a redundant docker tag (#15) 2026-07-21 20:09:32 +00:00
emi
11872d7e64 Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14) 2026-07-21 20:01:30 +00:00
emi
f21aa2c2e0 Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14) 2026-07-21 20:01:30 +00:00
emi
f25001db69 Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14) 2026-07-21 20:01:30 +00:00
emi
1738f61c8f Repo-split Stage 3: frontend SSR service (in-repo, not yet live) (#13) 2026-07-21 17:48:55 +00:00
emi
4d86bbce83 Repo-split Stage 3: frontend SSR service (in-repo, not yet live) (#13) 2026-07-21 17:48:55 +00:00
emi
9978754087 Emit a notifier heartbeat to the log stream for the dashboard (#12) 2026-07-21 16:52:55 +00:00
6137a08c23 Dashboard: notifier liveness from the heartbeat log
Replace the grep-docker-logs notifier panel with a per-node UP/DOWN stat
driven by the app's new tag=heartbeat log line (count_over_time over 20m;
0 beats => DOWN/red). Update the README now that the app emits heartbeats.
2026-07-21 09:51:14 -07:00
emi
ae69c45cb3 Remove the old scripts/dashboard.py (moved to thermograph-observability) (#11) 2026-07-21 16:32:26 +00:00
emi
e9e16e6b78 Remove the old scripts/dashboard.py (moved to thermograph-observability) (#11) 2026-07-21 16:32:26 +00:00
710d9d4fec Agents push to Loki over the mesh IP, not host loopback
A containerized Alloy agent can't reach the host's 127.0.0.1, so even the
beta-local agent must push to beta's wg0 address (10.10.0.2:3100), not
127.0.0.1. Fix the beta example in the README and the agent compose header.
2026-07-21 09:24:26 -07:00
emi
f71b23a152 Repo-split Stage 2: SSR content JSON API (#10) 2026-07-21 16:21:28 +00:00
7e8a484ad5 Use dashboard.thermograph.org + Google SSO for the dashboard
- Grafana served at dashboard.thermograph.org (the record now points at beta)
- Google OAuth (OIDC) login, env-gated (OAUTH_ENABLED + GOOGLE_CLIENT_ID/SECRET);
  allow_sign_up=false so access is locked to pre-provisioned accounts, with a
  break-glass local admin fallback
- README: Google Cloud OAuth client setup with the redirect URIs for both
  Grafana and Forgejo, plus the Forgejo add-oauth command
2026-07-21 09:18:29 -07:00
46edb0bca0 Fleet log aggregation: Grafana + Loki + Alloy over the WireGuard mesh
A central Grafana + Loki stack (on beta) fed by a Grafana Alloy agent on
every node, replacing the old SSH-tailed single-host scripts/dashboard.py.

- docker-compose.yml: central Loki (mesh-only :3100) + Grafana (Caddy-fronted)
- loki/config.yml: single-binary, filesystem storage, 30-day retention
- alloy/config.alloy + docker-compose.agent.yml: per-node collector — every
  container's stdout/stderr via the Docker socket, Caddy host logs, and the
  app's structured JSON logs (errors/access/audit), each line tagged by node
- grafana/: auto-provisioned Loki datasource + a fleet-logs dashboard
  (volume by service, error rate, upstream 429s, Caddy 5xx, notifier liveness,
  live tail), with a per-node selector
- caddy-grafana.conf, README, .env.example
2026-07-21 09:11:52 -07:00
emi
241b87c7b3 Repo-split Stage 1: sever web/'s reverse imports (#9) 2026-07-21 16:09:35 +00:00
emi
7b591439c1 Fix Postgres metrics: dropped counters + invisible notifier heartbeat (#8) 2026-07-21 15:52:28 +00:00
emi
e9df43e6c0 Sync docs to the 3-node / release-branch / Forgejo architecture (#7) 2026-07-21 15:11:21 +00:00
emi
01e29f38a3 Retire GitHub as the git host and CI platform (#6) 2026-07-21 14:00:57 +00:00
emi
a2054c4b24 Reconcile origin/dev and forgejo/dev (#5) 2026-07-21 09:51:58 +00:00
emi
871738d283 Fix the Forgejo LAN runner: Python provisioning, Docker access, registry push (#4) 2026-07-21 06:46:04 +00:00
emi
4e52a9c418 Wire account verification end-to-end and fix an RFC 5322 mail bug (#1) 2026-07-21 03:57:20 +00:00
emi
ac69fcea0f Wire account verification end-to-end and fix an RFC 5322 mail bug (#1) 2026-07-21 03:57:20 +00:00
emi
9ffd9d1cfb Wire account verification end-to-end and fix an RFC 5322 mail bug (#1) 2026-07-21 03:57:20 +00:00
Emi Griffith
441af55084 Route Forgejo through beta's existing Caddy instead of Traefik (#244)
beta (Forgejo's pinned node) is also today's live thermograph.org host,
with Caddy already bound to ports 80/443. A second reverse proxy
binding those same ports would collide with it. Drop Traefik entirely:
forgejo's web port now publishes to 127.0.0.1:3080 only, and a new
Caddy site block (deploy/forgejo/caddy-git.conf) reverse-proxies
git.thermograph.org to it, reusing Caddy's existing automatic-HTTPS
instead of a second ACME flow.

That same Caddy block resolves the registry-exposure question (hazard
#15 in the hop1 runbook) as its first listed option: /v2/* (the
registry API) is blocked to everything outside the WireGuard mesh
CIDR, while the git/web UI stays public. README documents the
/etc/hosts override mesh clients need so registry traffic actually
routes over the tunnel rather than the public IP.
2026-07-21 02:21:53 +00:00
Emi Griffith
7ff3873661 Realign Swarm/Forgejo infra to the 3-node design (#243)
Bring the Swarm+Forgejo layer in line with the canonical topology in
docs/runbooks/implementation-handoff.md: three nodes (prod, beta, and
the desktop LAN dev machine) instead of two, with the Forgejo Actions
runner registered on the desktop as a plain systemd service rather than
a Swarm-scheduled Docker-in-Docker sidecar.

- setup-wireguard.sh: full N-peer mesh instead of point-to-point
- docker-stack.yml: drop the runner/runner-dind services, volumes, and
  runner-token secret; only forgejo_db_password remains
- register-lan-runner.sh: register under both docker and
  thermograph-lan labels, since one runner now covers both job types
- init-swarm.sh, join-swarm.sh, swarm/README.md, forgejo/README.md,
  INFRA.md: updated node lists, order of operations, and access-state
  table for three nodes
- deploy.yml: renamed to "Deploy to beta VPS" and reconciled the
  main-vs-release branch question against terraform.tfvars.example
  (this workflow already targets beta; a release-triggered prod
  deploy doesn't exist yet and isn't invented here)
2026-07-21 01:43:19 +00:00
Emi Griffith
913563788b Add a worker scheduler for city warming and IndexNow pings (#242)
warm_cities.py and indexnow.py are one-shot scripts run manually at deploy
today - nothing keeps the curated city set topped up, or pings IndexNow,
between deploys.

Add notifications/scheduler.py: an in-process APScheduler instance driving
two recurring jobs (city warming, daily; IndexNow --if-changed, every 6h by
default), gated by the same leader election as the notifier - only the
process that already won leadership starts it, so the jobs run in exactly
one place under multi-host Swarm rather than once per replica. Neither job
runs immediately on start: warm_cities already runs at deploy time, so an
immediate duplicate on every worker boot/restart would be wasted work.

Extract indexnow.submit_if_changed() from the CLI's --if-changed branch so
the scheduler and the command line share the exact same skip-when-unchanged
logic instead of two copies drifting apart. The CLI's plain (non-flag) path
is unchanged.

APScheduler over a Postgres-native queue: exactly one process ever runs
this, no fan-out/backpressure/dead-letter need, no new infrastructure.
2026-07-21 01:26:39 +00:00