Port orphaned infra files from monorepo (Makefile, .env.example, migrate-db, twa, DEPLOY docs)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
This commit is contained in:
parent
ce454e0be6
commit
bbc9b28bfd
11 changed files with 994 additions and 0 deletions
42
.env.example
Normal file
42
.env.example
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Local docker-compose overrides. Copy to .env (gitignored) for `docker compose
|
||||||
|
# up` / `make up` on a dev box: cp .env.example .env
|
||||||
|
#
|
||||||
|
# Compose auto-reads a repo-root .env for ${VAR} interpolation in
|
||||||
|
# docker-compose.yml. In production these live in /etc/thermograph.env instead
|
||||||
|
# (loaded by the systemd unit), so this file is only for local runs.
|
||||||
|
|
||||||
|
# Database password. Compose uses it to initialize the postgres container AND to
|
||||||
|
# build the app's THERMOGRAPH_DATABASE_URL. Change it before first `up`.
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
|
||||||
|
# --- Everything below is optional -- docker-compose.yml already defaults each
|
||||||
|
# --- of these, so a plain `make up` works with none of it set. Uncomment to
|
||||||
|
# --- override.
|
||||||
|
|
||||||
|
# TimescaleDB image tag. Defaults to the floating latest-pg18 tag. Pin it to an
|
||||||
|
# exact minor (e.g. 2.17.2-pg18) before any host of this stack could ever
|
||||||
|
# replicate with another -- see docker-compose.yml's db service comment.
|
||||||
|
# TIMESCALEDB_TAG=latest-pg18
|
||||||
|
|
||||||
|
# Postgres sizing. Terraform sets these per host in prod/beta (prod DB_MEMORY
|
||||||
|
# 16g); local/beta default to 8g / 2 CPUs.
|
||||||
|
# DB_MEMORY=8g
|
||||||
|
# DB_CPUS=2
|
||||||
|
|
||||||
|
# Backend uvicorn worker count and CPU cap. Terraform raises these on bigger
|
||||||
|
# hosts; defaults keep a plain `docker compose up` identical to before.
|
||||||
|
# WORKERS=4
|
||||||
|
# APP_CPUS=4
|
||||||
|
|
||||||
|
# Frontend CPU cap.
|
||||||
|
# FRONTEND_CPUS=2
|
||||||
|
|
||||||
|
# Registry + per-service image path/tag. Local dev normally builds each image
|
||||||
|
# in its own app repo (thermograph-backend / thermograph-frontend) tagged
|
||||||
|
# :local, which is the default here -- only set these to pull a specific
|
||||||
|
# published build instead of building locally.
|
||||||
|
# REGISTRY_HOST=git.thermograph.org
|
||||||
|
# BACKEND_IMAGE_PATH=emi/thermograph-backend/app
|
||||||
|
# BACKEND_IMAGE_TAG=local
|
||||||
|
# FRONTEND_IMAGE_PATH=emi/thermograph-frontend/app
|
||||||
|
# FRONTEND_IMAGE_TAG=local
|
||||||
172
DEPLOY-DEV.md
Normal file
172
DEPLOY-DEV.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Dev CI/CD → LAN server on this machine
|
||||||
|
|
||||||
|
Parallel to the prod pipeline (`main` → VPS, see `DEPLOY.md`), the **`dev`**
|
||||||
|
branch continuously deploys to a **LAN server running on this computer**. Git
|
||||||
|
hosting and CI are self-hosted **Forgejo** (`git.thermograph.org`, reachable
|
||||||
|
at `http://10.10.0.2:3080` over the WireGuard mesh) — GitHub is retired.
|
||||||
|
|
||||||
|
```
|
||||||
|
open PR ──▶ CI (build + boot/health, on the LAN Forgejo runner)
|
||||||
|
│ green
|
||||||
|
▼
|
||||||
|
merge into dev (explicit — Forgejo does not auto-merge on its own;
|
||||||
|
see "Landing a PR" below)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LAN runner (thermograph-lan label) runs deploy/deploy-dev.sh
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
~/thermograph-dev updated ─▶ docker compose stack (backend + frontend +
|
||||||
|
Postgres 18) brought up, backend on 0.0.0.0:8137
|
||||||
|
```
|
||||||
|
|
||||||
|
Reachable at `http://<lan-ip>:8137/` from any device on your Wi-Fi.
|
||||||
|
|
||||||
|
The dev server runs the **same containerized stack as prod** (`docker-compose.yml`),
|
||||||
|
overlaid with `docker-compose.dev.yml`: **uncapped** (no CPU limits, unlike prod's
|
||||||
|
backend=4 / frontend=2 / db=2) and backend published on `0.0.0.0:8137` for the
|
||||||
|
LAN (prod binds loopback behind Caddy; frontend has no published port here
|
||||||
|
either way, no Caddy to reach it directly, so it's only reached through
|
||||||
|
backend's own reverse-proxy fallback). Bring it up by hand with `make dev-up`.
|
||||||
|
|
||||||
|
## Why it's built this way
|
||||||
|
|
||||||
|
Forgejo has native branch protection + required-status-checks + auto-merge
|
||||||
|
(unlike GitHub on a private free-tier repo, where those are paywalled). But
|
||||||
|
"auto-merge" here still means opting a PR in — either clicking **"Auto merge
|
||||||
|
when checks succeed"** in the web UI, or merging explicitly once CI is green
|
||||||
|
via the API (`POST .../pulls/{n}/merge`, `{"Do": "squash"}`). Nothing merges
|
||||||
|
a ready PR for you automatically just because checks pass, unlike GitHub's
|
||||||
|
old in-workflow `ci-cd.yml` (retired along with the rest of `.github/`).
|
||||||
|
|
||||||
|
## Moving parts
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `.forgejo/workflows/pr-build.yml` | required status check for PRs into `dev` (calls `build.yml`) |
|
||||||
|
| `.forgejo/workflows/deploy-dev.yml` | build + deploy on pushes to `dev` (direct, or a PR merge) |
|
||||||
|
| `.forgejo/workflows/build.yml` | shared build gate: deps, backend tests, JS syntax check, boot/health check |
|
||||||
|
| `deploy/deploy-dev.sh` | pull `dev` into `~/thermograph-dev`, `docker compose up` the uncapped LAN stack, health check |
|
||||||
|
| `docker-compose.dev.yml` | dev overlay: no CPU limits, backend published on `0.0.0.0:8137` (frontend unpublished) |
|
||||||
|
| `deploy/provision-dev-lan.sh` | one-time sudo-free bootstrap of the checkout + runner |
|
||||||
|
| `deploy/forgejo/register-lan-runner.sh` | registers the Forgejo Actions runner on this machine |
|
||||||
|
|
||||||
|
`deploy-dev.yml`'s `deploy` job runs on the `thermograph-lan` label — **not**
|
||||||
|
`[self-hosted, thermograph-lan]` (the array form is what the original GitHub
|
||||||
|
version used; GitHub implicitly tags every self-hosted runner `self-hosted`,
|
||||||
|
but Forgejo's runner has only the labels it was explicitly registered with,
|
||||||
|
so the array form is permanently unschedulable there — a real bug found and
|
||||||
|
fixed once, worth knowing about if a "deploy" job ever silently stops
|
||||||
|
appearing in the Actions history again).
|
||||||
|
|
||||||
|
`deploy-dev.sh`'s git auth (`GH_TOKEN`/`GITHUB_TOKEN`, Forgejo's per-job
|
||||||
|
token exposed under that name for compatibility) is scoped to `REPO_URL`'s
|
||||||
|
own host via a per-command `http.<scheme>://<host>/.extraheader`, not a
|
||||||
|
hardcoded one — important if the Forgejo host ever changes, since a stale
|
||||||
|
hardcoded host would make git silently skip the header (no error) and fall
|
||||||
|
through to whatever ambient credentials happen to be available, not fail
|
||||||
|
loudly.
|
||||||
|
|
||||||
|
## The self-hosted runner
|
||||||
|
|
||||||
|
A Forgejo Actions **runner** (`forgejo-runner`) runs on this machine, labels
|
||||||
|
`docker` (containerized jobs, node:20-bookworm, with the host's docker.sock
|
||||||
|
automounted in — `container.docker_host: automount` in
|
||||||
|
`~/forgejo-runner/config.yaml` — so `docker build`/`push` work without
|
||||||
|
privileged Docker-in-Docker) and `thermograph-lan` (host-native jobs, for
|
||||||
|
`deploy-dev.sh`'s systemctl/docker-compose calls). Installed under
|
||||||
|
`~/forgejo-runner`, runs as the `forgejo-runner` systemd `--user` service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# status / logs
|
||||||
|
systemctl --user status forgejo-runner
|
||||||
|
journalctl --user -u forgejo-runner -f
|
||||||
|
|
||||||
|
# re-register if the token/host ever changes
|
||||||
|
bash deploy/forgejo/register-lan-runner.sh <forgejo_url> <registration_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
`git.thermograph.org`'s public DNS resolves to beta's **public** IP, which
|
||||||
|
the registry's mesh-only ACL (see `deploy/forgejo/README.md`) rejects for
|
||||||
|
`/v2/` paths — `docker build-push.yml` pushes run against the *host's*
|
||||||
|
automounted daemon, so it's this **host's** own resolver that needs to route
|
||||||
|
git.thermograph.org over the mesh, not anything settable from a job
|
||||||
|
container. If registry pushes ever start failing with an HTTP/TLS mismatch
|
||||||
|
or a 403 on `/v2/`, check `getent hosts git.thermograph.org` here — it needs
|
||||||
|
to resolve to beta's WireGuard IP (`10.10.0.2`), typically via a `/etc/hosts`
|
||||||
|
line, not the public one.
|
||||||
|
|
||||||
|
## The LAN dev service (Docker Compose)
|
||||||
|
|
||||||
|
Runs as a Docker Compose stack, not a bare systemd unit — `docker ps --filter
|
||||||
|
name=thermograph-dev` shows `thermograph-dev-backend-1`, `thermograph-dev-frontend-1`
|
||||||
|
(repo-split Stage 4 split the single "app" container in two — frontend has no
|
||||||
|
published port, reached only through backend's own reverse-proxy fallback) and
|
||||||
|
`thermograph-dev-db-1` (TimescaleDB). A legacy pre-container
|
||||||
|
`thermograph-dev.service` systemd --user unit may still exist from before this
|
||||||
|
cutover; it's stale and `deploy-dev.sh` stops/disables it on every run — don't
|
||||||
|
trust `systemctl --user status thermograph-dev` as a liveness check, use `docker
|
||||||
|
ps` instead.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter name=thermograph-dev # is it up?
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend frontend # app logs (from ~/thermograph-dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap (or rebuild) it by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/provision-dev-lan.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Config: port/base path are baked into the unit from `deploy/deploy-dev.sh`
|
||||||
|
(`PORT`, `THERMOGRAPH_BASE`). Change them there and redeploy. The dedicated
|
||||||
|
checkout at `~/thermograph-dev` is separate from your working tree, so a deploy
|
||||||
|
never touches uncommitted edits in `~/Code/Thermograph`.
|
||||||
|
|
||||||
|
## Before monitoring a PR to land
|
||||||
|
|
||||||
|
A PR needs an explicit merge once CI is green — but first confirm it's
|
||||||
|
actually set up to land, or you'll monitor a PR that can never merge:
|
||||||
|
|
||||||
|
1. **Not conflicting** — `GET /api/v1/repos/{owner}/{repo}/pulls/{n}` should
|
||||||
|
show `"mergeable": true`. If not, merge `forgejo/dev` in and resolve now —
|
||||||
|
don't monitor a PR that can't merge.
|
||||||
|
2. **Required check actually configured** — `GET
|
||||||
|
/api/v1/repos/{owner}/{repo}/branch_protections/dev` should list
|
||||||
|
`status_check_contexts` containing the *exact* string Forgejo reports for
|
||||||
|
that PR's check (workflow display name + job name + event, e.g. `"PR build
|
||||||
|
(required check) / build (pull_request)"` — not just the job name
|
||||||
|
`"build"`). A mismatch here means no PR can ever satisfy the check even
|
||||||
|
with fully green CI; this bit us once already.
|
||||||
|
3. **CI actually running** — `GET
|
||||||
|
/api/v1/repos/{owner}/{repo}/commits/{sha}/status` shows a status, not an
|
||||||
|
empty list. If empty, check the runner is up
|
||||||
|
(`systemctl --user status forgejo-runner`).
|
||||||
|
|
||||||
|
Only start monitoring once all three pass. Once CI is green and mergeable is
|
||||||
|
true, merge explicitly: `POST /api/v1/repos/{owner}/{repo}/pulls/{n}/merge`
|
||||||
|
with `{"Do": "squash", "delete_branch_after_merge": true}`.
|
||||||
|
|
||||||
|
## Day-to-day
|
||||||
|
|
||||||
|
- **Ship:** open a PR into `dev`, confirm CI is green, merge it explicitly —
|
||||||
|
the merge triggers the deploy here.
|
||||||
|
- **Manual redeploy:** trigger `deploy-dev.yml` via `workflow_dispatch`
|
||||||
|
(`POST /api/v1/repos/{owner}/{repo}/actions/workflows/deploy-dev.yml/dispatches`,
|
||||||
|
`{"ref": "dev"}`), or locally `APP_DIR=~/thermograph-dev bash deploy/deploy-dev.sh`.
|
||||||
|
- **Promote to prod:** merge/fast-forward `dev` into `main` and push — that
|
||||||
|
triggers the VPS pipeline in `DEPLOY.md`.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Logs and dashboards moved to a **separate project** — a central Grafana + Loki
|
||||||
|
stack fed by a Grafana Alloy agent on every node (including this LAN dev box),
|
||||||
|
over the WireGuard mesh (`thermograph-observability` on Forgejo; UI at
|
||||||
|
`https://dashboard.thermograph.org`). The dev node's Alloy agent ships its
|
||||||
|
container/app logs under `host="dev"`, so LAN dev shows up alongside prod and
|
||||||
|
beta in the same dashboards. This replaced the old `scripts/dashboard.py`.
|
||||||
|
|
||||||
|
For a quick terminal check without Grafana, the app still serves raw counters at
|
||||||
|
the gated `GET /api/v2/metrics` route (loopback-only; set
|
||||||
|
`THERMOGRAPH_METRICS_TOKEN` to read it over an SSH tunnel).
|
||||||
385
DEPLOY.md
Normal file
385
DEPLOY.md
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
# Deploying Thermograph to a prod VPS
|
||||||
|
|
||||||
|
> **Prod and beta are now provisioned by Terraform** (`terraform/README.md`) and
|
||||||
|
> run as a **`docker compose` stack** (backend + frontend + Postgres/TimescaleDB;
|
||||||
|
> repo-split Stage 4 split the single "app" service in two), not the
|
||||||
|
> manual venv + systemd model this document originally described. Current shape:
|
||||||
|
> prod = new box `169.58.46.181`, branch `release`, deployed with `terraform
|
||||||
|
> apply`; beta = old box `75.119.132.91`, branch `main`, deployed when `main` is
|
||||||
|
> pushed (`.forgejo/workflows/deploy.yml` SSHes in and runs `deploy/deploy.sh`,
|
||||||
|
> which `docker compose pull`s the image `build-push.yml` already pushed for
|
||||||
|
> that commit and `up`s it -- repo-split Stage 6's registry-pull cutover;
|
||||||
|
> `deploy.sh` no longer builds in place). The **manual walkthrough below
|
||||||
|
> (`provision.sh`, `thermograph.service`, the venv) is legacy** — kept as a
|
||||||
|
> from-scratch, no-Terraform reference; the process model is compose, so the
|
||||||
|
> systemd/venv specifics no longer match how prod/beta actually run.
|
||||||
|
|
||||||
|
Pipeline (beta, on `main`): **push to `main` on Forgejo → `build-push.yml` builds
|
||||||
|
+ pushes the image, tagged by SHA → Forgejo Actions SSHes to beta →
|
||||||
|
`deploy/deploy.sh` (`git reset` + `docker login` + `docker compose pull && up`,
|
||||||
|
schema migration runs in the app entrypoint) → Caddy fronts it with Let's Encrypt
|
||||||
|
TLS.** Prod (on `release`) is deployed with `terraform apply`, not a push
|
||||||
|
trigger — its `remote-exec` provisioner does the same pull-instead-of-build.
|
||||||
|
(GitHub is retired/archived — Forgejo, self-hosted at `git.thermograph.org` /
|
||||||
|
`10.10.0.2:3080` over the WireGuard mesh, is now the sole git host and CI.)
|
||||||
|
|
||||||
|
Files that make this work:
|
||||||
|
|
||||||
|
| File | Where it lives in prod | Purpose |
|
||||||
|
|------|------------------------|---------|
|
||||||
|
| `.forgejo/workflows/deploy.yml` | Forgejo | CI job that SSHes in and runs the deploy script (beta, on `main`) |
|
||||||
|
| `.forgejo/workflows/build-push.yml` | Forgejo | builds + pushes the SHA-tagged image `deploy.sh`/Terraform pull |
|
||||||
|
| `deploy/deploy.sh` | `/opt/thermograph/deploy/deploy.sh` | `git reset` + `docker login` + `docker compose pull && up` + health check + warm |
|
||||||
|
| `docker-compose.yml` | `/opt/thermograph/docker-compose.yml` | app + Postgres/TimescaleDB services (the process model) |
|
||||||
|
| `deploy/thermograph.env.example` | `/etc/thermograph.env` | Postgres password, VAPID/auth secrets, `WORKERS`, sizing, base path |
|
||||||
|
| `deploy/Caddyfile` | `/etc/caddy/Caddyfile` | reverse proxy + automatic HTTPS |
|
||||||
|
| `terraform/` | run from your machine | provisions the box + brings the stack up (replaces `provision.sh`) |
|
||||||
|
| `deploy/provision.sh`, `deploy/thermograph.service` | *(legacy)* | pre-compose venv+systemd bootstrap — superseded by Terraform |
|
||||||
|
|
||||||
|
The app serves on **loopback only**; Caddy is the only thing exposed to the
|
||||||
|
internet (ports 80/443). The parquet cache in `data/cache/` lives inside the
|
||||||
|
`/opt/thermograph` checkout and is gitignored, so `git pull` never touches it.
|
||||||
|
|
||||||
|
**Any deploy host needs a `/etc/hosts` entry for the registry.**
|
||||||
|
`git.thermograph.org`'s public DNS resolves to beta's public IP, and beta's
|
||||||
|
own Caddy rejects `/v2/*` (the registry API) from anything outside the
|
||||||
|
WireGuard mesh (10.10.0.0/24) — confirmed live: `docker login`/`pull` from
|
||||||
|
prod failed with a 403 until adding `10.10.0.2 git.thermograph.org` to
|
||||||
|
`/etc/hosts` (10.10.0.2 is beta's mesh IP; every deploy host is already on
|
||||||
|
the mesh, this is purely a DNS-routing gap, not a connectivity one). Beta
|
||||||
|
itself doesn't need this — it's the box Forgejo runs on, so `git.thermograph.org`
|
||||||
|
just resolves to itself either way. `build-push.yml`'s own header comment
|
||||||
|
documents the same requirement for the CI runner host.
|
||||||
|
|
||||||
|
**Current production layout** (`deploy/Caddyfile`): the app owns
|
||||||
|
**`thermograph.org`** at its root (`THERMOGRAPH_BASE=/`, so uvicorn serves `/`,
|
||||||
|
`/calendar`, `/api/v2/…` with no prefix), and **`emigriffith.dev`** serves a
|
||||||
|
static portfolio at its root with `emigriffith.dev/thermograph*` permanently
|
||||||
|
redirecting to `thermograph.org` (prefix stripped). Both domains' `A` records
|
||||||
|
point at the same VPS; Caddy provisions a separate cert for each.
|
||||||
|
|
||||||
|
> **Note:** `deploy.sh` (the CI deploy) only pulls code, reinstalls deps, and
|
||||||
|
> restarts the app service — it does **not** touch Caddy or `/etc/thermograph.env`.
|
||||||
|
> After changing `deploy/Caddyfile` or the env, copy it onto the VPS and reload
|
||||||
|
> Caddy / restart the service by hand (see Part 2 step 4 and Part 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Keys: how to manage them
|
||||||
|
|
||||||
|
You need **two** separate SSH keypairs. Don't reuse one for both.
|
||||||
|
|
||||||
|
### Key A — your login key (you ↔ VPS), you probably already have this
|
||||||
|
This is how *you* SSH in to run `provision.sh`. Nothing to set up here beyond
|
||||||
|
your existing access.
|
||||||
|
|
||||||
|
### Key B — the CI deploy key (Forgejo Actions ↔ VPS), you create this
|
||||||
|
A dedicated key whose **private half** lives in a Forgejo Actions secret and
|
||||||
|
whose **public half** is authorized on the VPS's deploy user. It should only
|
||||||
|
be able to log in as `deploy` and run the deploy script.
|
||||||
|
|
||||||
|
Generate it on your machine (no passphrase — CI can't type one):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -C "thermograph-ci" -f ~/.ssh/thermograph_ci -N ""
|
||||||
|
```
|
||||||
|
|
||||||
|
That makes two files:
|
||||||
|
- `~/.ssh/thermograph_ci` → **private** key → goes into the Forgejo Actions secret `SSH_KEY`
|
||||||
|
- `~/.ssh/thermograph_ci.pub` → **public** key → goes on the VPS
|
||||||
|
|
||||||
|
Install the **public** key on the VPS (as the `deploy` user):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-copy-id -i ~/.ssh/thermograph_ci.pub deploy@YOUR_VPS_IP
|
||||||
|
# or manually: append the .pub line to /home/deploy/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the **private** key into Forgejo → repo **Settings → Actions → Secrets →
|
||||||
|
Add Secret**. Set all of these:
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `SSH_KEY` | full contents of `~/.ssh/thermograph_ci` (the private key, incl. BEGIN/END lines) |
|
||||||
|
| `SSH_HOST` | VPS IP or hostname |
|
||||||
|
| `SSH_USER` | `deploy` |
|
||||||
|
| `SSH_PORT` | `22` (or your custom SSH port) |
|
||||||
|
|
||||||
|
No `tea` CLI is installed for a command-line alternative; use the web UI, or
|
||||||
|
the REST API with a personal access token:
|
||||||
|
`POST /api/v1/repos/{owner}/{repo}/actions/secrets/{name}` (body `{"data": "..."}`).
|
||||||
|
|
||||||
|
**Why I can't do this for you:** Forgejo secrets are write-only and require
|
||||||
|
your Forgejo auth; the VPS's `authorized_keys` requires your SSH access. Both
|
||||||
|
are credentials only you should hold.
|
||||||
|
|
||||||
|
### Deploy key for cloning a private repo (optional)
|
||||||
|
If the Forgejo repo is **private**, the VPS also needs to *pull* from it. Two
|
||||||
|
options:
|
||||||
|
- Make `provision.sh`'s `REPO_URL` an **HTTPS** URL with an embedded personal
|
||||||
|
access token (what the live VPS checkout actually uses today — see its
|
||||||
|
`origin` remote), or
|
||||||
|
- Add the `deploy` user's own key (`ssh-keygen` on the VPS) as a **read-only
|
||||||
|
Deploy Key** in the repo (Settings → Deploy keys). This is separate from Key B.
|
||||||
|
|
||||||
|
If the repo is **public**, skip this — `git pull` needs no auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Install (once, on the VPS)
|
||||||
|
|
||||||
|
1. **Create the Forgejo repo and push** (this project isn't versioned yet):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Code/Thermograph
|
||||||
|
git init && git add -A && git commit -m "Initial commit"
|
||||||
|
# create an empty private repo on the Forgejo instance first (web UI or
|
||||||
|
# POST /api/v1/user/repos), then:
|
||||||
|
git remote add origin https://<forgejo-host>/OWNER/thermograph.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Point DNS** for each domain you serve (`thermograph.org` for the app,
|
||||||
|
`emigriffith.dev` for the portfolio) at the VPS IP with an `A` record. Let's
|
||||||
|
Encrypt won't issue a cert until the name resolves to the box.
|
||||||
|
|
||||||
|
3. **Provision the box.** Copy `deploy/provision.sh` over (or clone the repo)
|
||||||
|
and edit `REPO_URL` at the top, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REPO_URL="https://<forgejo-host>/OWNER/thermograph.git" bash deploy/provision.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It installs Python + git + Caddy, creates the `deploy` user, clones to
|
||||||
|
`/opt/thermograph`, installs the systemd unit + env file, grants the deploy
|
||||||
|
user a password-less `systemctl restart thermograph` (so CI can restart it),
|
||||||
|
and starts the service.
|
||||||
|
|
||||||
|
4. **Install the Caddy config** and reload. `deploy/Caddyfile` already carries the
|
||||||
|
real domains (`thermograph.org` + `emigriffith.dev`); edit them if yours differ,
|
||||||
|
then copy it into place, validate, and reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /opt/thermograph/deploy/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
sudo caddy validate --config /etc/caddy/Caddyfile # syntax check before reload
|
||||||
|
sudo systemctl reload caddy # Caddy fetches each domain's TLS cert automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this again any time `deploy/Caddyfile` changes — the CI deploy does not ship
|
||||||
|
it. Set the app's base path to match in `/etc/thermograph.env`
|
||||||
|
(`THERMOGRAPH_BASE=/` for the app-at-root layout), then `sudo systemctl restart
|
||||||
|
thermograph`.
|
||||||
|
|
||||||
|
5. **Install Key B** (Part 1) so CI can log in.
|
||||||
|
|
||||||
|
6. **Verify:** open `https://YOUR_DOMAIN/` — you should get a clean padlock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Day-to-day
|
||||||
|
|
||||||
|
- **Deploy:** just `git push` to `main`. Actions runs `deploy.sh` and
|
||||||
|
health-checks the service. Watch it under the repo's **Actions** tab on
|
||||||
|
Forgejo (or query `/api/v1/repos/{owner}/{repo}/actions/tasks`).
|
||||||
|
- **Manual deploy / rollback:** `ssh deploy@vps '/opt/thermograph/deploy/deploy.sh'`
|
||||||
|
(set `BRANCH=` or check out a tag first to roll back).
|
||||||
|
- **Logs:** `journalctl -u thermograph -f` (app), `/var/log/caddy/thermograph.log` (proxy).
|
||||||
|
- **Restart:** `sudo systemctl restart thermograph`.
|
||||||
|
- **Change port / base path:** edit `/etc/thermograph.env`, then `sudo systemctl
|
||||||
|
restart thermograph`. Changing the base path usually pairs with a Caddy change
|
||||||
|
(proxy target / redirects) — update `/etc/caddy/Caddyfile` and reload Caddy too.
|
||||||
|
- **Change routing / domains:** edit `/etc/caddy/Caddyfile` (or copy the repo's),
|
||||||
|
`sudo caddy validate --config /etc/caddy/Caddyfile`, then `sudo systemctl reload
|
||||||
|
caddy`. The CI deploy never ships the Caddyfile, so this is always by hand.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
- App is bound to `127.0.0.1` — not reachable except through Caddy.
|
||||||
|
- Firewall: allow only `22`, `80`, `443` (e.g. `ufw allow 22,80,443/tcp`).
|
||||||
|
- The CI sudoers rule is scoped to exactly `systemctl restart/status thermograph`.
|
||||||
|
- Rotate Key B by regenerating it and updating the `SSH_KEY` secret +
|
||||||
|
`authorized_keys`.
|
||||||
|
|
||||||
|
## Accounts & notifications
|
||||||
|
|
||||||
|
Accounts, subscriptions, and notifications are **authoritative and not
|
||||||
|
regenerable** — back them up. On **prod/beta they live in Postgres/TimescaleDB**
|
||||||
|
(the compose `db` service, on the `pgdata` volume) alongside the climate record;
|
||||||
|
on **LAN dev** they fall back to SQLite (`data/accounts.sqlite`) when
|
||||||
|
`THERMOGRAPH_DATABASE_URL` isn't a Postgres URL.
|
||||||
|
|
||||||
|
- **Back up the Postgres volume** (e.g. `pg_dump` the `thermograph` database) as
|
||||||
|
part of the VPS backup routine — losing it wipes all accounts and alerts. (On
|
||||||
|
a SQLite LAN dev box, back up `data/accounts.sqlite` and its `-wal`/`-shm`
|
||||||
|
sidecars instead.)
|
||||||
|
- **Multiple workers, one notifier (leader election).** Prod runs several uvicorn
|
||||||
|
workers (`WORKERS`, currently 8 on prod / 4 on beta). The subscription
|
||||||
|
evaluator and the recurring scheduler must run in exactly one, so the workers
|
||||||
|
elect a single leader — a host-local `flock` (`THERMOGRAPH_SINGLETON_LOCK`,
|
||||||
|
set by the compose app service to `/app/data/notifier.lock`), or a cluster-wide
|
||||||
|
Postgres advisory lock (`THERMOGRAPH_SINGLETON_PG=1`) if the notifier must be
|
||||||
|
single across *hosts*. See `backend/core/singleton.py`. `THERMOGRAPH_ROLE`
|
||||||
|
(`web`/`worker`/`all`, default `all`) further restricts which processes may own
|
||||||
|
it, so a stateless web tier can scale without scaling notifiers.
|
||||||
|
- **Env vars** (all optional, sensible defaults):
|
||||||
|
- `THERMOGRAPH_COOKIE_SECURE` — set to `1` when serving over HTTPS (the VPS/TLS
|
||||||
|
deploy) so the session cookie is `Secure`. Leave unset for plain-HTTP LAN, where
|
||||||
|
a `Secure` cookie would never be sent.
|
||||||
|
- `THERMOGRAPH_SESSION_TTL_DAYS` — login/cookie lifetime (default 30).
|
||||||
|
- `THERMOGRAPH_NOTIFY_INTERVAL` — seconds between evaluation passes (default 900).
|
||||||
|
- `THERMOGRAPH_NOTIFY_ARCHIVE_FETCHES` — max missing-archive fetches per pass
|
||||||
|
(default 4). A newly-subscribed cell with no cached ~45-year archive is fetched
|
||||||
|
once (then only read); this caps how many such fetches one pass may do so a burst
|
||||||
|
of new subscriptions can't exhaust the archive quota.
|
||||||
|
- `THERMOGRAPH_ENABLE_NOTIFIER` — set to `0` to disable the background evaluator.
|
||||||
|
- `THERMOGRAPH_AUTH_SECRET` — signing secret for (future) email reset/verify
|
||||||
|
tokens; unused today, a per-boot random value is used if unset.
|
||||||
|
|
||||||
|
### Web Push (VAPID)
|
||||||
|
|
||||||
|
Push notifications are signed with a VAPID keypair. If `THERMOGRAPH_VAPID_PRIVATE_KEY`
|
||||||
|
/ `THERMOGRAPH_VAPID_PUBLIC_KEY` are unset, the app generates a pair into
|
||||||
|
**`data/vapid.json`** on first run. That's fine **only if the file persists** — the
|
||||||
|
key the browser subscribed with must keep matching the key the server signs with. If
|
||||||
|
the keys ever change (regenerated `data/vapid.json`, a non-persistent data dir), every
|
||||||
|
existing subscription silently stops delivering: the push service returns 401/403 and
|
||||||
|
the app records it (`logs/errors/*.jsonl`, tagged `"phase":"push"`) but the `/push/test`
|
||||||
|
call still returns 202 with `sent:0, failed:N`. To be safe, **pin the keys** in
|
||||||
|
`/etc/thermograph.env` (see `deploy/thermograph.env.example` for the generate command).
|
||||||
|
After changing keys, users must toggle alerts off/on to re-subscribe.
|
||||||
|
|
||||||
|
Diagnose a "test says sent but nothing arrives": check the `/push/test` response
|
||||||
|
(`failed:N` ⇒ delivery rejected) or `tail logs/errors/*.jsonl | grep push`, and
|
||||||
|
`docker compose logs backend | grep -i push` for the exact status code.
|
||||||
|
|
||||||
|
## Homepage "unusual right now" feed
|
||||||
|
|
||||||
|
`backend/api/homepage.py` sweeps the tracked cities, grades each one's latest
|
||||||
|
observation from the parquet cache, and writes the ranked result to
|
||||||
|
`data/homepage.json` for the homepage template. It runs on the notifier's timer
|
||||||
|
(hourly guard) and at the tail of `warm_cities.py`, so a deploy repopulates it.
|
||||||
|
|
||||||
|
**It spends a small amount of forecast quota, and has to.** Nothing else keeps
|
||||||
|
the recent/forecast cache current for the tracked cities: `warm_cities.py` skips
|
||||||
|
any city whose archive is already cached, so it never re-fetches their forecast
|
||||||
|
bundle, and the notifier only touches cells somebody has subscribed to. Reading
|
||||||
|
that cache without refreshing it made the strip present days-old readings as
|
||||||
|
"right now". Each pass therefore tops up the **stalest** cells first, capped:
|
||||||
|
|
||||||
|
- `THERMOGRAPH_HOMEPAGE_REFRESH` — recent/forecast fetches per pass (default 40,
|
||||||
|
one per cell). At the hourly cadence that is ~960/day. Set `0` to disable, in
|
||||||
|
which case the strip only ranks cities something else happened to refresh.
|
||||||
|
- `THERMOGRAPH_HOMEPAGE_CITIES` — how many cities the strip ranks over (default
|
||||||
|
250). The strip shows ~12 cards; ranking over a smaller set that is genuinely
|
||||||
|
fresh beats ranking over every cached city when most of those are stale.
|
||||||
|
|
||||||
|
Readings older than a day are dropped rather than ranked, so an empty strip means
|
||||||
|
the cache went cold — not that the weather is unremarkable.
|
||||||
|
|
||||||
|
## Outbound email
|
||||||
|
|
||||||
|
The app never talks to a mail provider directly. It speaks plain SMTP to
|
||||||
|
**Postfix running as a send-only null client on `127.0.0.1:25`**
|
||||||
|
(`deploy/provision-mail.sh`, run once as root). `backend/mailer.py` is the only
|
||||||
|
code that sends, over stdlib `smtplib` — no new dependency.
|
||||||
|
|
||||||
|
Why route through a local MTA rather than a provider's API:
|
||||||
|
|
||||||
|
- **Delivery policy stays swappable.** Direct-to-MX or relayed through a
|
||||||
|
transactional provider is a Postfix setting; the app's config is just
|
||||||
|
"localhost:25" either way, so switching needs no code change or redeploy.
|
||||||
|
- **Postfix queues and retries.** A handler hands the message off in
|
||||||
|
microseconds; a slow or briefly-down upstream can't stall a request or lose a
|
||||||
|
signup.
|
||||||
|
- **Nothing new is exposed.** `inet_interfaces = loopback-only`, so the box
|
||||||
|
accepts mail from itself and nothing else. A loopback socket is not a
|
||||||
|
filesystem write, so the hardened unit (`ProtectSystem=full`,
|
||||||
|
`ReadWritePaths=…`) needs no change.
|
||||||
|
|
||||||
|
**Default is safe:** `THERMOGRAPH_MAIL_BACKEND` defaults to `console` — it logs
|
||||||
|
the message and sends nothing — so LAN dev and the test suite exercise the whole
|
||||||
|
signup path with no mail server and no risk of mailing a real person. Production
|
||||||
|
opts in with `=smtp`.
|
||||||
|
|
||||||
|
### Deliverability (do this before mailing real subscribers)
|
||||||
|
|
||||||
|
Mail from a bare VPS IP is very often junked no matter how Postfix is configured,
|
||||||
|
because the IP has no sending reputation. Either:
|
||||||
|
|
||||||
|
- **Relay through a provider** (recommended) — `RELAYHOST` + credentials in
|
||||||
|
`provision-mail.sh`. They handle SPF/DKIM alignment and reputation.
|
||||||
|
- **Direct to MX** — then you own the DNS work: an **SPF** TXT record, **DKIM**
|
||||||
|
(opendkim) with the public key published, a **DMARC** TXT record, and a
|
||||||
|
**PTR / reverse-DNS** record on the VPS IP pointing at `mail.thermograph.org`.
|
||||||
|
Missing rDNS alone is enough for Gmail and Outlook to junk everything.
|
||||||
|
|
||||||
|
Check placement with <https://www.mail-tester.com>, which scores all four at once.
|
||||||
|
|
||||||
|
- **Env vars** (all in `deploy/thermograph.env.example`):
|
||||||
|
`THERMOGRAPH_MAIL_BACKEND` (`console` | `smtp` | `disabled`),
|
||||||
|
`THERMOGRAPH_SMTP_HOST` / `_PORT` / `_USER` / `_PASSWORD` / `_STARTTLS`,
|
||||||
|
`THERMOGRAPH_MAIL_FROM`, `THERMOGRAPH_MAIL_REPLY_TO`.
|
||||||
|
- **`THERMOGRAPH_AUTH_SECRET` must be set to a fixed value** before any
|
||||||
|
confirmation or password-reset link is mailed. It currently defaults to a
|
||||||
|
per-boot random value, so every outstanding link would break on restart.
|
||||||
|
- **Digest signups** land in the `pending_digest` table in the accounts DB
|
||||||
|
(Postgres on prod/beta; authoritative, back it up). The form ships ahead of
|
||||||
|
delivery on purpose:
|
||||||
|
addresses are collected now and confirmed once SMTP is live.
|
||||||
|
- **Logs:** `journalctl -u postfix -f`; queue with `mailq`.
|
||||||
|
|
||||||
|
## SEO content pages
|
||||||
|
|
||||||
|
Crawlable, server-rendered pages (Jinja2) sit alongside the interactive tool and
|
||||||
|
link into it: `/climate` (hub), `/climate/<slug>` (per-city), `/climate/<slug>/<month>`,
|
||||||
|
`/climate/<slug>/records`, `/glossary`, `/about`, plus `/robots.txt` and
|
||||||
|
`/sitemap.xml`. The routable city set is `backend/cities.json` (~1000 metros: top
|
||||||
|
~500 global + ~250 core-English + ~250 extended-English/high-proficiency),
|
||||||
|
regenerated with `python gen_cities.py [n_global] [n_english] [n_extended]`.
|
||||||
|
|
||||||
|
- A **values filter** in `gen_cities.py` drops cities in countries that criminalize
|
||||||
|
LGBTQ+ people (plus China/Pakistan/Indonesia by choice) via `EXCLUDE_CC`, except a
|
||||||
|
hand-kept list of notable/tourist/high-English hubs in `KEEP_SLUGS` (Lagos, Nairobi,
|
||||||
|
Cairo, Dubai, KL, Shanghai×5-from-China, Karachi, Jakarta, …). Excluded slots are
|
||||||
|
backfilled from the next-ranked non-excluded cities, so the total stays ~1000. Edit
|
||||||
|
either set and regenerate to change the policy.
|
||||||
|
|
||||||
|
- **Per-city blurbs** live in `backend/cities_flavor.json` (a short Wikipedia summary
|
||||||
|
per city, CC BY-SA, attributed on the page). `python gen_flavor.py` fetches
|
||||||
|
Wikipedia's free REST summary API and validates each match by coordinates; it's
|
||||||
|
**incremental** by default (only fetches cities missing a blurb, and prunes ones no
|
||||||
|
longer in cities.json) — pass `--full` to rebuild. ~94% of cities get a blurb; the
|
||||||
|
rest render without one.
|
||||||
|
- **Notable weather events** are a small **hand-curated** list in `backend/city_events.py`
|
||||||
|
(`{slug: {text, url}}`) — one verified iconic event per city (Katrina/New Orleans,
|
||||||
|
Harvey/Houston, the 1952 Great Smog/London, …). Auto-sourcing these from search
|
||||||
|
proved unreliable (wrong matches), so they're edited by hand; a city not in the list
|
||||||
|
simply shows its flavor blurb. Add entries freely — a test checks the slugs are valid.
|
||||||
|
|
||||||
|
- **Archive warming runs automatically on every deploy.** `deploy.sh` (prod) and
|
||||||
|
`deploy-dev.sh` (dev) launch `warm_cities.py` detached in the background after the
|
||||||
|
health check, so the ~750 city pages serve from cache and a crawl can't burst the
|
||||||
|
archive API quota. It's idempotent (skips already-cached cells), so only the first
|
||||||
|
deploy does the full ~25-min warm; later deploys just top up new cities. A page
|
||||||
|
also self-heals (fetches its archive once) if hit before warming finishes. To run
|
||||||
|
it by hand: `cd backend && python warm_cities.py --pace 2`. Logs: prod
|
||||||
|
`logs/warm-cities.log`; dev `journalctl --user -u thermograph-warm-cities`.
|
||||||
|
- **Submit the sitemap** in Google Search Console: `https://thermograph.org/sitemap.xml`.
|
||||||
|
- `jinja2` is a new dependency (already in `requirements.txt`).
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Fleet-wide logs and dashboards live in a **separate project** — a central
|
||||||
|
Grafana + Loki stack fed by a Grafana Alloy agent on every node, over the
|
||||||
|
WireGuard mesh (`thermograph-observability` on Forgejo; UI at
|
||||||
|
**`https://dashboard.thermograph.org`**, Google SSO). It ingests every
|
||||||
|
container's stdout, Caddy's access logs, and the app's structured JSON logs
|
||||||
|
(`logs/{errors,access,audit}/*.jsonl`). This replaced the old SSH-tailed
|
||||||
|
`scripts/dashboard.py`.
|
||||||
|
|
||||||
|
The app still exposes raw counters at the gated `GET /api/v2/metrics` route
|
||||||
|
(loopback-only; refuses any request carrying a proxy `X-Forwarded-*` header, so
|
||||||
|
it's never reachable through Caddy). It reports inbound requests per endpoint,
|
||||||
|
outbound calls per upstream source, and background-daemon heartbeats
|
||||||
|
(e.g. the subscription notifier). Read it over an SSH tunnel with
|
||||||
|
`THERMOGRAPH_METRICS_TOKEN` (set in `/etc/thermograph.env`) as the `token=`
|
||||||
|
query param — handy for a quick check without opening Grafana.
|
||||||
61
Makefile
Normal file
61
Makefile
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# docker-compose orchestration only. The app-level targets that used to live
|
||||||
|
# here (run, lan-run, stop, migrate, migrate-cache, test, shots, indexnow,
|
||||||
|
# venv, clean) moved to the backend/frontend app repos along with the code
|
||||||
|
# they operate on.
|
||||||
|
|
||||||
|
.PHONY: up down db-up db-down dev-up dev-down om-up om-down om-backfill
|
||||||
|
|
||||||
|
# --- docker-compose stack (backend + frontend + PostgreSQL) ---------------------
|
||||||
|
# Requires a POSTGRES_PASSWORD (copy .env.example -> .env). See docker-compose.yml.
|
||||||
|
|
||||||
|
# Pull each service's published image and start the whole stack (backend +
|
||||||
|
# frontend + db) in the background. docker-compose.yml no longer has a
|
||||||
|
# top-level `build:` for backend/frontend -- each ships as its OWN image
|
||||||
|
# (BACKEND_IMAGE_TAG / FRONTEND_IMAGE_TAG), built in its own app repo (a plain
|
||||||
|
# `docker build` there, or that repo's own build-push.yml in CI) -- so `up`
|
||||||
|
# here only pulls + runs; it can no longer build in place.
|
||||||
|
up:
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Stop and remove the stack's containers (named volumes persist).
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Start just the Postgres service (e.g. to run the app against it from a venv).
|
||||||
|
db-up:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
# Stop the Postgres service.
|
||||||
|
db-down:
|
||||||
|
docker compose stop db
|
||||||
|
|
||||||
|
# The LAN dev stack: uncapped (no CPU limits) and published on 0.0.0.0:8137 so
|
||||||
|
# phones on the Wi-Fi can reach it (the base file is prod: capped + loopback).
|
||||||
|
DEV_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
dev-up:
|
||||||
|
POSTGRES_PASSWORD=$${POSTGRES_PASSWORD:-thermograph-dev} $(DEV_COMPOSE) up -d --build
|
||||||
|
|
||||||
|
dev-down:
|
||||||
|
$(DEV_COMPOSE) down
|
||||||
|
|
||||||
|
# Self-hosted Open-Meteo (prod-only overlay). Serves the ERA5 archive from object
|
||||||
|
# storage so the app is off the public archive API. See deploy/openmeteo/README.md
|
||||||
|
# for the object-storage bucket + rclone mount this expects on the host.
|
||||||
|
OM_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml
|
||||||
|
om-up:
|
||||||
|
$(OM_COMPOSE) up -d --build
|
||||||
|
|
||||||
|
om-down:
|
||||||
|
$(OM_COMPOSE) down
|
||||||
|
|
||||||
|
# One-time historical backfill: write the full 1980→present archive (~1–1.5 TB of
|
||||||
|
# .om) to object storage. Runs each dataset once (no --repeat-interval), independent
|
||||||
|
# of the app/db, so it can run before the stack is flipped over. Hours-long.
|
||||||
|
OM_LAND_VARS = temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m
|
||||||
|
OM_ERA5_VARS = wind_gusts_10m,temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m
|
||||||
|
om-backfill:
|
||||||
|
$(OM_COMPOSE) run --rm --no-deps open-meteo-sync-land \
|
||||||
|
sync copernicus_era5_land $(OM_LAND_VARS) --past-days $${OM_BACKFILL_DAYS:-17000}
|
||||||
|
$(OM_COMPOSE) run --rm --no-deps open-meteo-sync-era5 \
|
||||||
|
sync copernicus_era5 $(OM_ERA5_VARS) --past-days $${OM_BACKFILL_DAYS:-17000}
|
||||||
118
deploy/migrate-db.py
Normal file
118
deploy/migrate-db.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply pending SQL migrations to the accounts database.
|
||||||
|
|
||||||
|
This project has no Alembic. SQLAlchemy ``create_all()`` only creates *missing
|
||||||
|
tables*, so column additions to the long-lived accounts DB are done with the
|
||||||
|
hand-written ``.sql`` files in ``deploy/migrations/``. This runner applies the
|
||||||
|
ones a given database hasn't seen yet and records them in a ``schema_migrations``
|
||||||
|
table, so it is safe (and cheap) to run on every deploy.
|
||||||
|
|
||||||
|
It resolves the database exactly as the app does (``db.py``):
|
||||||
|
``THERMOGRAPH_ACCOUNTS_DB`` if set, otherwise ``<repo>/data/accounts.sqlite``.
|
||||||
|
|
||||||
|
Fresh database: the app's ``create_all()`` builds the *current* schema on first
|
||||||
|
start, so there is nothing to ALTER. This runner detects that (no file yet, or no
|
||||||
|
``user`` table) and records the existing migrations as a baseline instead of
|
||||||
|
replaying them, so they never run against a schema that already has the columns.
|
||||||
|
|
||||||
|
Usage (wired into deploy.sh / deploy-dev.sh, but runnable by hand too):
|
||||||
|
|
||||||
|
/opt/thermograph/.venv/bin/python deploy/migrate-db.py
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(HERE)
|
||||||
|
MIGRATIONS_DIR = os.path.join(HERE, "migrations")
|
||||||
|
DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.join(REPO, "data", "accounts.sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
def _applied(conn: sqlite3.Connection) -> set[str]:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||||
|
"name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
return {row[0] for row in conn.execute("SELECT name FROM schema_migrations")}
|
||||||
|
|
||||||
|
|
||||||
|
def _record(conn: sqlite3.Connection, name: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO schema_migrations(name, applied_at) "
|
||||||
|
"VALUES (?, datetime('now'))",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_user_table(conn: sqlite3.Connection) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='user'"
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
migrations = sorted(glob.glob(os.path.join(MIGRATIONS_DIR, "*.sql")))
|
||||||
|
if not migrations:
|
||||||
|
print("migrate-db: no migrations found")
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
fresh = not os.path.exists(DB_PATH)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
try:
|
||||||
|
applied = _applied(conn)
|
||||||
|
pending = [m for m in migrations if os.path.basename(m) not in applied]
|
||||||
|
if not pending:
|
||||||
|
print(f"migrate-db: up to date ({len(applied)} applied) -> {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fresh DB (or one whose accounts schema hasn't been built yet): the app's
|
||||||
|
# create_all() will produce the current schema on next start, so record the
|
||||||
|
# existing migrations as a baseline rather than ALTERing a table that isn't
|
||||||
|
# there. Only genuinely older databases need the ALTERs replayed.
|
||||||
|
if fresh or not _has_user_table(conn):
|
||||||
|
for m in pending:
|
||||||
|
_record(conn, os.path.basename(m))
|
||||||
|
conn.commit()
|
||||||
|
print(f"migrate-db: fresh database, baselined {len(pending)} migration(s) -> {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# These are the authoritative, non-regenerable accounts. Back the DB up
|
||||||
|
# before touching it, next to the DB with a timestamp.
|
||||||
|
backup = f"{DB_PATH}.bak-{time.strftime('%Y%m%d-%H%M%S')}"
|
||||||
|
shutil.copy2(DB_PATH, backup)
|
||||||
|
print(f"migrate-db: backed up -> {backup}")
|
||||||
|
|
||||||
|
for m in pending:
|
||||||
|
name = os.path.basename(m)
|
||||||
|
with open(m, encoding="utf-8") as f:
|
||||||
|
sql = f.read()
|
||||||
|
try:
|
||||||
|
conn.executescript(sql)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
msg = str(e).lower()
|
||||||
|
# Idempotent: a column/index this migration adds already exists
|
||||||
|
# (applied by hand before, or created fresh from the model). Treat
|
||||||
|
# as already-applied and record it; re-raise anything unexpected.
|
||||||
|
if "duplicate column" in msg or "already exists" in msg:
|
||||||
|
print(f"migrate-db: {name} already present in schema, recording")
|
||||||
|
else:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"migrate-db: FAILED on {name}: {e}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
_record(conn, name)
|
||||||
|
conn.commit()
|
||||||
|
print(f"migrate-db: applied {name}")
|
||||||
|
|
||||||
|
print(f"migrate-db: done ({len(pending)} newly recorded) -> {DB_PATH}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
17
deploy/migrations/001-user-discord-id.sql
Normal file
17
deploy/migrations/001-user-discord-id.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- Adds User.discord_id for Discord account linking (backend/discord_link.py).
|
||||||
|
--
|
||||||
|
-- This project has no Alembic: SQLAlchemy create_all() only creates *missing
|
||||||
|
-- tables*, so it will NOT add this column to an existing accounts DB. deploy.sh
|
||||||
|
-- and deploy-dev.sh run deploy/migrate-db.py, which applies the pending files
|
||||||
|
-- here on every deploy (tracked in a schema_migrations table, backing the DB up
|
||||||
|
-- first). A fresh DB gets the column + uniqueness from the model and is baselined
|
||||||
|
-- rather than ALTERed. To apply by hand instead (back the DB up first):
|
||||||
|
--
|
||||||
|
-- sqlite3 /opt/thermograph/data/accounts.sqlite < deploy/migrations/001-user-discord-id.sql
|
||||||
|
|
||||||
|
ALTER TABLE user ADD COLUMN discord_id VARCHAR(32);
|
||||||
|
|
||||||
|
-- SQLite can't add a UNIQUE column via ALTER, so enforce it with a partial unique
|
||||||
|
-- index (NULLs are allowed to repeat; a real id links to at most one account).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ix_user_discord_id
|
||||||
|
ON user (discord_id) WHERE discord_id IS NOT NULL;
|
||||||
9
deploy/migrations/002-user-discord-dm.sql
Normal file
9
deploy/migrations/002-user-discord-dm.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Adds User.discord_dm for Discord DM alert opt-in (notify.py DM channel).
|
||||||
|
-- Same no-Alembic caveat as 001: create_all won't add a column to an existing
|
||||||
|
-- accounts DB. deploy/migrate-db.py applies this on every deploy; existing rows
|
||||||
|
-- default to 0 (DMs off) until the user opts in by linking Discord. To apply by
|
||||||
|
-- hand instead (back the DB up first):
|
||||||
|
--
|
||||||
|
-- sqlite3 /opt/thermograph/data/accounts.sqlite < deploy/migrations/002-user-discord-dm.sql
|
||||||
|
|
||||||
|
ALTER TABLE user ADD COLUMN discord_dm BOOLEAN NOT NULL DEFAULT 0;
|
||||||
30
deploy/provision-dev-lan.sh
Executable file
30
deploy/provision-dev-lan.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time bootstrap for the Thermograph LAN dev server on THIS machine.
|
||||||
|
#
|
||||||
|
# Sudo-free: the app runs as a Docker Compose stack (see deploy-dev.sh), and
|
||||||
|
# `linger` keeps the runner/services running across logout/reboot. Re-runnable
|
||||||
|
# (idempotent).
|
||||||
|
#
|
||||||
|
# bash deploy/provision-dev-lan.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-$HOME/thermograph-dev}"
|
||||||
|
REPO_URL="${REPO_URL:-http://10.10.0.2:3080/emi/thermograph.git}"
|
||||||
|
BRANCH="${BRANCH:-dev}"
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Enabling linger so the service survives logout/reboot"
|
||||||
|
loginctl enable-linger "$USER" \
|
||||||
|
|| echo " (couldn't enable linger; the service still runs while you're logged in)"
|
||||||
|
|
||||||
|
echo "==> Cloning/refreshing $APP_DIR and starting the service"
|
||||||
|
APP_DIR="$APP_DIR" REPO_URL="$REPO_URL" BRANCH="$BRANCH" bash "$here/deploy-dev.sh"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Done. The dev server runs as the 'thermograph-dev' systemd --user service.
|
||||||
|
status: systemctl --user status thermograph-dev
|
||||||
|
logs: journalctl --user -u thermograph-dev -f
|
||||||
|
restart: systemctl --user restart thermograph-dev
|
||||||
|
stop: systemctl --user stop thermograph-dev
|
||||||
|
EOF
|
||||||
42
deploy/secrets/dry-run-render.sh
Executable file
42
deploy/secrets/dry-run-render.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run ON a prod/beta box (via `ssh <box> 'bash -s' < this`), after the encrypted vault
|
||||||
|
# file has been scp'd to /tmp/tg-<env>.yaml. NON-DESTRUCTIVE go/no-go for the cutover:
|
||||||
|
# renders the vault and diffs the KEY=VALUE set against the live /etc/thermograph.env.
|
||||||
|
# Prints only PASS/FAIL and any differing key NAMES — never secret values. No git, no
|
||||||
|
# writes; touches nothing. (The encrypted file is safe to ship; only ciphertext.)
|
||||||
|
set -euo pipefail
|
||||||
|
key=/etc/thermograph/age.key
|
||||||
|
env_name="$(sudo cat /etc/thermograph/secrets-env)"
|
||||||
|
host="/tmp/tg-${env_name}.yaml"
|
||||||
|
common="/tmp/tg-common.yaml"
|
||||||
|
|
||||||
|
command -v sops >/dev/null || { echo "FAIL: sops not installed"; exit 1; }
|
||||||
|
[ -f "$host" ] || { echo "FAIL: $host not found — scp the encrypted vault file first"; exit 1; }
|
||||||
|
|
||||||
|
keymat="$(sudo grep '^AGE-SECRET-KEY-' "$key")"
|
||||||
|
tmp="$(mktemp)"; live="$(mktemp)"; trap 'rm -f "$tmp" "$live"' EXIT
|
||||||
|
: > "$tmp"
|
||||||
|
[ -f "$common" ] && SOPS_AGE_KEY="$keymat" sops -d --input-type yaml --output-type dotenv "$common" >> "$tmp"
|
||||||
|
SOPS_AGE_KEY="$keymat" sops -d --input-type yaml --output-type dotenv "$host" >> "$tmp"
|
||||||
|
|
||||||
|
sudo cat /etc/thermograph.env > "$live"
|
||||||
|
python3 - "$live" "$tmp" <<'PY'
|
||||||
|
import sys
|
||||||
|
def load(p):
|
||||||
|
out={}
|
||||||
|
for line in open(p, encoding="utf-8"):
|
||||||
|
s=line.rstrip("\n").strip()
|
||||||
|
if not s or s.startswith("#") or "=" not in s: continue
|
||||||
|
k,_,v=s.partition("="); out[k.strip()]=v
|
||||||
|
return out
|
||||||
|
live,rend=load(sys.argv[1]),load(sys.argv[2])
|
||||||
|
lost=sorted(set(live)-set(rend)); added=sorted(set(rend)-set(live))
|
||||||
|
changed=sorted(k for k in live if k in rend and live[k]!=rend[k])
|
||||||
|
if lost or added or changed:
|
||||||
|
print("FAIL — the render would NOT match the live env")
|
||||||
|
if lost: print(" lost keys:", lost)
|
||||||
|
if added: print(" extra keys:", added)
|
||||||
|
if changed: print(" value-changed keys:", changed)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"PASS — all {len(live)} keys render byte-identical to the live /etc/thermograph.env")
|
||||||
|
PY
|
||||||
85
deploy/twa/README.md
Normal file
85
deploy/twa/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Thermograph Android app (Trusted Web Activity)
|
||||||
|
|
||||||
|
The Android app is a **Trusted Web Activity (TWA)**: a thin native shell that opens
|
||||||
|
`https://thermograph.org` full-screen in the user's Chrome engine. It *is* the live
|
||||||
|
PWA — no content is duplicated, and the existing service worker + VAPID **web push
|
||||||
|
keeps working** inside the app via Android notification delegation (no Firebase, no
|
||||||
|
backend change). `enableNotifications: true` in `twa-manifest.json` turns that on
|
||||||
|
and wires the Android 13+ `POST_NOTIFICATIONS` runtime permission.
|
||||||
|
|
||||||
|
This folder holds the build config. **Building the APK is a local/manual step** —
|
||||||
|
it needs a JDK, the Android SDK, and a signing keystore that only you should hold.
|
||||||
|
The current goal is a **signed APK you sideload for testing**; Play Store
|
||||||
|
submission ($25 one-time) is deferred.
|
||||||
|
|
||||||
|
## What's in the repo
|
||||||
|
- `twa-manifest.json` — Bubblewrap config (reference values; see comment inside).
|
||||||
|
- `../../frontend/.well-known/assetlinks.json` — Digital Asset Links, served at
|
||||||
|
`https://thermograph.org/.well-known/assetlinks.json`. Its fingerprint is a
|
||||||
|
**placeholder** until you generate the signing key (step 4).
|
||||||
|
|
||||||
|
## One-time build (you)
|
||||||
|
|
||||||
|
Prereqs: Node 18+, JDK 17. Bubblewrap can download the Android SDK for you.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @bubblewrap/cli
|
||||||
|
|
||||||
|
# 1. Scaffold from the live manifest (creates ./twa-manifest.json + a keystore).
|
||||||
|
# Accept defaults, then reconcile with this repo's twa-manifest.json — in
|
||||||
|
# particular set: packageId = org.thermograph.twa, enableNotifications = true.
|
||||||
|
bubblewrap init --manifest https://thermograph.org/manifest.webmanifest
|
||||||
|
|
||||||
|
# 2. (Or) copy this repo's config and let Bubblewrap fill the SDK bits:
|
||||||
|
# cp deploy/twa/twa-manifest.json ./twa-manifest.json && bubblewrap update
|
||||||
|
|
||||||
|
# 3. Build the signed APK (and .aab for the store, for later).
|
||||||
|
bubblewrap build
|
||||||
|
# -> ./app-release-signed.apk and ./app-release-bundle.aab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wire Digital Asset Links (required — or the app opens with a URL bar)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Print the signing key's SHA-256 fingerprint:
|
||||||
|
bubblewrap fingerprint list # or: keytool -list -v -keystore android-keystore.jks
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the `SHA256` value into `frontend/.well-known/assetlinks.json`, replacing
|
||||||
|
`REPLACE_WITH_TWA_SIGNING_KEY_SHA256_FINGERPRINT`, then **deploy the site** so the
|
||||||
|
new file is live. Confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://thermograph.org/.well-known/assetlinks.json # must show your fingerprint, Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you later publish to Play Store with **Play App Signing**, add *Google's* app
|
||||||
|
> signing certificate fingerprint (from the Play Console) to `assetlinks.json` too —
|
||||||
|
> the upload-key fingerprint alone won't verify the store build.
|
||||||
|
|
||||||
|
### 5. Sideload and test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install app-release-signed.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
On the device:
|
||||||
|
- Launch Thermograph — it must open **full-screen with no browser address bar**
|
||||||
|
(that confirms assetlinks verified).
|
||||||
|
- Subscribe to a city (or trigger a test alert) and confirm the "unusual weather"
|
||||||
|
push arrives as a **native Android notification**.
|
||||||
|
- Grant the notifications permission when prompted (Android 13+).
|
||||||
|
|
||||||
|
## Keep the keystore safe
|
||||||
|
`android-keystore.jks` is required for **every future update** and for the
|
||||||
|
eventual Play Store listing. Losing it means a new package identity and a fresh
|
||||||
|
install for every user. Store it out of the repo, backed up.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
- **Play Store listing** ($25 one-time Google Play developer account). Upload the
|
||||||
|
`.aab`; the store review for a well-formed TWA is light. Not part of this pass.
|
||||||
|
|
||||||
|
## Why no backend changes
|
||||||
|
Android delegates the TWA's web notifications to the OS, so `backend/push.py`
|
||||||
|
(VAPID) and `backend/notify.py` deliver push to the app unchanged. Nothing in
|
||||||
|
`backend/` is touched by this track.
|
||||||
33
deploy/twa/twa-manifest.json
Normal file
33
deploy/twa/twa-manifest.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"_comment": "Bubblewrap config for the Thermograph Trusted Web Activity. `bubblewrap init --manifest https://thermograph.org/manifest.webmanifest` scaffolds a file like this; the values below are the ones that matter for us (packageId must match /.well-known/assetlinks.json, enableNotifications wires web-push delegation, colors come from manifest.webmanifest). See README.md in this folder.",
|
||||||
|
"packageId": "org.thermograph.twa",
|
||||||
|
"host": "thermograph.org",
|
||||||
|
"name": "Thermograph",
|
||||||
|
"launcherName": "Thermograph",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "default",
|
||||||
|
"themeColor": "#f0803c",
|
||||||
|
"themeColorDark": "#0f1216",
|
||||||
|
"navigationColor": "#171b21",
|
||||||
|
"navigationColorDark": "#0f1216",
|
||||||
|
"navigationColorDivider": "#2a323c",
|
||||||
|
"navigationColorDividerDark": "#2a323c",
|
||||||
|
"backgroundColor": "#171b21",
|
||||||
|
"enableNotifications": true,
|
||||||
|
"startUrl": "/",
|
||||||
|
"webManifestUrl": "https://thermograph.org/manifest.webmanifest",
|
||||||
|
"iconUrl": "https://thermograph.org/logo.png?v=3",
|
||||||
|
"maskableIconUrl": "https://thermograph.org/logo-maskable-512.png?v=3",
|
||||||
|
"monochromeIconUrl": "https://thermograph.org/favicon.svg?v=3",
|
||||||
|
"fallbackType": "customtabs",
|
||||||
|
"features": {},
|
||||||
|
"signingKey": {
|
||||||
|
"path": "./android-keystore.jks",
|
||||||
|
"alias": "thermograph"
|
||||||
|
},
|
||||||
|
"appVersionName": "1.0.0",
|
||||||
|
"appVersionCode": 1,
|
||||||
|
"minSdkVersion": 23,
|
||||||
|
"shortcuts": [],
|
||||||
|
"generatorApp": "bubblewrap-cli"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue