2026-07-11 00:29:47 +00:00
|
|
|
#!/usr/bin/env bash
|
2026-07-22 18:39:16 +00:00
|
|
|
# Pull this INFRA repo's checkout up to date, then roll ONE (or all) of the
|
|
|
|
|
# docker-compose app services onto its separately-published image. Run on the
|
|
|
|
|
# VPS — each app repo's Forgejo Actions workflow invokes this over SSH (see
|
|
|
|
|
# .forgejo/workflows/deploy.yml in thermograph-backend / thermograph-frontend),
|
|
|
|
|
# and you can run it by hand too.
|
2026-07-11 00:29:47 +00:00
|
|
|
#
|
2026-07-22 18:39:16 +00:00
|
|
|
# # roll just the backend onto a specific image:
|
|
|
|
|
# ssh deploy@vps 'SERVICE=backend BACKEND_IMAGE_TAG=sha-<12hex> /opt/thermograph/deploy/deploy.sh'
|
|
|
|
|
# # roll just the frontend:
|
|
|
|
|
# ssh deploy@vps 'SERVICE=frontend FRONTEND_IMAGE_TAG=sha-<12hex> /opt/thermograph/deploy/deploy.sh'
|
|
|
|
|
# # bring the whole stack up (both tags required):
|
|
|
|
|
# ssh deploy@vps 'SERVICE=all BACKEND_IMAGE_TAG=sha-<a> FRONTEND_IMAGE_TAG=sha-<b> /opt/thermograph/deploy/deploy.sh'
|
Decouple Terraform from the app repo; add a GCP host scaffold
Content-change pass following the extraction from the app monorepo (this repo
now stands alone, sourced via git filter-repo to preserve history):
- terraform/variables.tf, secrets.tf, modules/thermograph-host: remove every
app-secret Terraform variable (postgres_password, auth_secret, VAPID keys,
registry_token, Discord/SMTP creds, ...) and the random_password/random_id
generators. The SOPS+age vault (deploy/secrets/*.yaml) is now the sole
source of app secrets, rendered at deploy time by deploy/render-secrets.sh;
Terraform renders only a non-secret /etc/thermograph-topology.env (sizing,
routing) via the renamed thermograph-topology.env.tftpl template.
- hosts gains a required app_image_tag field: the host's own checkout is now
this infra repo, not the app repo, so there is no "current commit" to
derive an image tag from — every host pins one explicitly. repo_url now
points at this repo (private; typically needs an embedded read token).
- deploy.sh: IMAGE_TAG is now required from the environment instead of
derived via `git rev-parse HEAD` of the (now infra-repo) checkout, which
would have silently resolved to the wrong or a nonexistent tag.
- New terraform/modules/gcp-host: creates a GCE VM + minimal VPC/firewall
only, then feeds its IP into the same thermograph-host module every
SSH-managed host already uses — one provisioning path regardless of how a
host came to exist. var.gcp_hosts defaults to {}, so no google_* resource
is planned and the provider is never invoked without it (verified: plan
and validate succeed with no GCP credentials configured).
- terraform/README.md, ACCESS.md (renamed from INFRA.md), README.md: updated
for the new secrets model, the GCP scaffold, and this repo's own identity.
Verified: terraform fmt/validate/init clean; plan succeeds against realistic
dummy hosts (prod+beta shape) and against a populated gcp_hosts entry (plans
6 resources with no live credentials, confirming the composition wires
correctly end to end).
2026-07-22 04:46:05 +00:00
|
|
|
#
|
2026-07-22 18:39:16 +00:00
|
|
|
# FE/BE CI-CD split: backend and frontend are published from separate repos as
|
|
|
|
|
# separate images (emi/thermograph-backend/app, emi/thermograph-frontend/app),
|
|
|
|
|
# so a deploy targets ONE service and leaves the other's running container +
|
|
|
|
|
# tag untouched. Each service's live tag is persisted host-side in
|
|
|
|
|
# deploy/.image-tags.env (untracked -- survives the git reset below) so a
|
|
|
|
|
# single-service roll re-renders compose with BOTH services' real tags and
|
|
|
|
|
# never accidentally recreates or downgrades the sibling.
|
|
|
|
|
#
|
|
|
|
|
# This checkout is thermograph-infra, not an app repo: BRANCH is this repo's
|
Decouple Terraform from the app repo; add a GCP host scaffold
Content-change pass following the extraction from the app monorepo (this repo
now stands alone, sourced via git filter-repo to preserve history):
- terraform/variables.tf, secrets.tf, modules/thermograph-host: remove every
app-secret Terraform variable (postgres_password, auth_secret, VAPID keys,
registry_token, Discord/SMTP creds, ...) and the random_password/random_id
generators. The SOPS+age vault (deploy/secrets/*.yaml) is now the sole
source of app secrets, rendered at deploy time by deploy/render-secrets.sh;
Terraform renders only a non-secret /etc/thermograph-topology.env (sizing,
routing) via the renamed thermograph-topology.env.tftpl template.
- hosts gains a required app_image_tag field: the host's own checkout is now
this infra repo, not the app repo, so there is no "current commit" to
derive an image tag from — every host pins one explicitly. repo_url now
points at this repo (private; typically needs an embedded read token).
- deploy.sh: IMAGE_TAG is now required from the environment instead of
derived via `git rev-parse HEAD` of the (now infra-repo) checkout, which
would have silently resolved to the wrong or a nonexistent tag.
- New terraform/modules/gcp-host: creates a GCE VM + minimal VPC/firewall
only, then feeds its IP into the same thermograph-host module every
SSH-managed host already uses — one provisioning path regardless of how a
host came to exist. var.gcp_hosts defaults to {}, so no google_* resource
is planned and the provider is never invoked without it (verified: plan
and validate succeed with no GCP credentials configured).
- terraform/README.md, ACCESS.md (renamed from INFRA.md), README.md: updated
for the new secrets model, the GCP scaffold, and this repo's own identity.
Verified: terraform fmt/validate/init clean; plan succeeds against realistic
dummy hosts (prod+beta shape) and against a populated gcp_hosts entry (plans
6 resources with no live credentials, confirming the composition wires
correctly end to end).
2026-07-22 04:46:05 +00:00
|
|
|
# branch (compose files, db init, the secrets vault, this script itself);
|
2026-07-22 18:39:16 +00:00
|
|
|
# the *_IMAGE_TAG values are the separately-published app images to run -- the
|
|
|
|
|
# two axes are independent and rarely change together.
|
2026-07-11 00:29:47 +00:00
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
APP_DIR="${APP_DIR:-/opt/thermograph}"
|
|
|
|
|
BRANCH="${BRANCH:-main}"
|
2026-07-22 18:39:16 +00:00
|
|
|
# Which service this deploy rolls: backend | frontend | all. Defaults to `all`
|
|
|
|
|
# (a full-stack bring-up) so a by-hand run with both tags still works; the
|
|
|
|
|
# per-repo deploy.yml workflows always pass an explicit single service.
|
|
|
|
|
SERVICE="${SERVICE:-all}"
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
HEALTH_PORT="${HEALTH_PORT:-8137}"
|
2026-07-11 00:29:47 +00:00
|
|
|
cd "$APP_DIR"
|
|
|
|
|
|
2026-07-22 18:39:16 +00:00
|
|
|
case "$SERVICE" in
|
|
|
|
|
backend|frontend|all) ;;
|
|
|
|
|
*) echo "!! SERVICE must be backend|frontend|all, got '$SERVICE'" >&2; exit 2 ;;
|
|
|
|
|
esac
|
|
|
|
|
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
# Secrets (POSTGRES_PASSWORD, VAPID keys, AUTH_SECRET, ...) drive compose
|
2026-07-21 20:01:30 +00:00
|
|
|
# interpolation and are also loaded into the backend container via env_file.
|
2026-07-22 03:21:46 +00:00
|
|
|
#
|
|
|
|
|
# When this host is configured for SOPS (an age key + /etc/thermograph/secrets-env),
|
|
|
|
|
# first render /etc/thermograph.env from the committed encrypted source of truth
|
|
|
|
|
# (deploy/secrets/*.yaml) so a key rotation is just an edit+commit+deploy. The guard
|
|
|
|
|
# on the helper's existence keeps the very deploy that INTRODUCES this file safe: on
|
|
|
|
|
# the first pass the checkout may predate it (it arrives with the git reset below,
|
|
|
|
|
# after which deploy.sh re-execs), so a missing helper simply falls back to the
|
|
|
|
|
# existing /etc/thermograph.env. Then source it so a by-hand run interpolates the
|
|
|
|
|
# same as the systemd unit does. See deploy/render-secrets.sh + deploy/secrets/.
|
|
|
|
|
if [ -f "$APP_DIR/deploy/render-secrets.sh" ]; then
|
|
|
|
|
# shellcheck source=deploy/render-secrets.sh
|
|
|
|
|
. "$APP_DIR/deploy/render-secrets.sh"
|
|
|
|
|
render_thermograph_secrets "$APP_DIR"
|
|
|
|
|
fi
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
set -a; . /etc/thermograph.env 2>/dev/null || true; set +a
|
|
|
|
|
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
# Pre-warm the ~750 city-page archives so /climate pages serve from cache and a
|
2026-07-21 20:01:30 +00:00
|
|
|
# search-engine crawl never bursts the archive API quota. Detached inside the
|
|
|
|
|
# backend container (compose exec -d), idempotent (skips already-cached cells),
|
|
|
|
|
# so it never blocks the deploy or health check and is cheap on every deploy
|
|
|
|
|
# after the first full warm.
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
warm_city_archives() {
|
2026-07-21 20:01:30 +00:00
|
|
|
echo "==> Warming city-page archives in the background (backend:/app/logs/warm-cities.log)"
|
|
|
|
|
docker compose exec -d backend sh -c \
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
'python warm_cities.py --pace 2 >> /app/logs/warm-cities.log 2>&1' || true
|
SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.
Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 20:38:59 +00:00
|
|
|
# Notify IndexNow (Bing / DuckDuckGo / Yandex) of the site's URLs, but only when
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
# the set of pages actually changed (a new/removed city) — code-only deploys skip.
|
|
|
|
|
# Best-effort: never fails the deploy.
|
2026-07-16 20:38:59 +00:00
|
|
|
ping_indexnow() {
|
|
|
|
|
echo "==> Pinging IndexNow (only if the URL set changed)"
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
local base="${THERMOGRAPH_BASE_URL:-https://thermograph.org}"
|
2026-07-21 20:01:30 +00:00
|
|
|
docker compose exec -T backend python indexnow.py --if-changed "$base" \
|
Containerize the app and move the databases to PostgreSQL 18 (#220)
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
2026-07-20 06:28:23 +00:00
|
|
|
|| echo "!! IndexNow ping failed (non-fatal)" >&2
|
2026-07-16 20:38:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
echo "==> Fetching $BRANCH"
|
|
|
|
|
git fetch --prune origin "$BRANCH"
|
|
|
|
|
git reset --hard "origin/$BRANCH"
|
|
|
|
|
|
2026-07-22 00:15:33 +00:00
|
|
|
# Re-exec: git reset --hard just rewrote this very file's bytes on disk while
|
|
|
|
|
# it's still running. bash reads a script via buffered, byte-offset I/O, so
|
|
|
|
|
# anything AFTER this point in the OLD execution can read from the wrong
|
|
|
|
|
# offset once the file's size/content changed underneath it -- a classic
|
|
|
|
|
# self-modifying-script footgun. Confirmed live: after this PR added ~15
|
|
|
|
|
# lines above, one deploy ran with the OLD "Building images" log lines even
|
|
|
|
|
# though `git status` showed the checkout correctly at the NEW commit --
|
|
|
|
|
# the file changed under a running interpreter, not the checkout. Restart
|
|
|
|
|
# fresh from the now-updated file so everything after this line is
|
|
|
|
|
# guaranteed self-consistent. Guarded so the second invocation doesn't
|
|
|
|
|
# fetch+reset+re-exec forever.
|
|
|
|
|
if [ -z "${DEPLOY_SH_REEXECED:-}" ]; then
|
|
|
|
|
export DEPLOY_SH_REEXECED=1
|
|
|
|
|
exec "$0" "$@"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-07-22 18:39:16 +00:00
|
|
|
# Registry-pull cutover: pull the image each app repo's build-push.yml already
|
|
|
|
|
# built and pushed, instead of building in place. This checkout is
|
|
|
|
|
# thermograph-infra, not an app repo, so there's no "current commit" to derive
|
|
|
|
|
# a tag from -- the caller (the deploying repo's deploy.yml) exports its own
|
|
|
|
|
# service's tag (BACKEND_IMAGE_TAG or FRONTEND_IMAGE_TAG = sha-<12 hex> of the
|
|
|
|
|
# app commit, or a semver tag).
|
2026-07-21 21:36:03 +00:00
|
|
|
REGISTRY_HOST="${REGISTRY_HOST:-git.thermograph.org}"
|
2026-07-22 18:39:16 +00:00
|
|
|
export REGISTRY_HOST BACKEND_IMAGE_PATH FRONTEND_IMAGE_PATH
|
|
|
|
|
|
|
|
|
|
# Load the last-deployed tag for BOTH services first, so a single-service roll
|
|
|
|
|
# still renders compose with the sibling's real, currently-running tag (never a
|
|
|
|
|
# bare `local` that would recreate/downgrade it). The incoming env for the
|
|
|
|
|
# service being deployed then overrides its line below. This file is untracked
|
|
|
|
|
# (see .gitignore), so `git reset --hard` above leaves it in place.
|
|
|
|
|
TAGS_FILE="$APP_DIR/deploy/.image-tags.env"
|
|
|
|
|
if [ -f "$TAGS_FILE" ]; then
|
|
|
|
|
set -a; . "$TAGS_FILE"; set +a
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Guard: the service(s) being rolled MUST have a concrete tag supplied now (the
|
|
|
|
|
# sibling's may come from the persisted file). `all` needs both.
|
|
|
|
|
case "$SERVICE" in
|
|
|
|
|
backend) : "${BACKEND_IMAGE_TAG:?set BACKEND_IMAGE_TAG=sha-<12-hex> for a backend deploy}" ;;
|
|
|
|
|
frontend) : "${FRONTEND_IMAGE_TAG:?set FRONTEND_IMAGE_TAG=sha-<12-hex> for a frontend deploy}" ;;
|
|
|
|
|
all)
|
|
|
|
|
: "${BACKEND_IMAGE_TAG:?set BACKEND_IMAGE_TAG=sha-<12-hex> (SERVICE=all needs both)}"
|
|
|
|
|
: "${FRONTEND_IMAGE_TAG:?set FRONTEND_IMAGE_TAG=sha-<12-hex> (SERVICE=all needs both)}"
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
# Compose interpolates both vars for the whole file even when we act on one
|
|
|
|
|
# service; default the not-yet-known sibling (first-ever deploy) to `local` so
|
|
|
|
|
# interpolation doesn't warn -- harmless since --no-deps never touches it.
|
|
|
|
|
export BACKEND_IMAGE_TAG="${BACKEND_IMAGE_TAG:-local}"
|
|
|
|
|
export FRONTEND_IMAGE_TAG="${FRONTEND_IMAGE_TAG:-local}"
|
|
|
|
|
|
|
|
|
|
# Which compose services this run pulls/rolls.
|
|
|
|
|
case "$SERVICE" in
|
|
|
|
|
backend) TARGETS=(backend) ;;
|
|
|
|
|
frontend) TARGETS=(frontend) ;;
|
|
|
|
|
all) TARGETS=(backend frontend) ;;
|
|
|
|
|
esac
|
2026-07-21 21:36:03 +00:00
|
|
|
|
2026-07-22 22:27:30 +00:00
|
|
|
# Login only when a token is supplied. The SSH/CI deploy paths (deploy.yml,
|
|
|
|
|
# deploy-prod.yml, deploy-dev on the LAN runner) don't pass REGISTRY_TOKEN --
|
|
|
|
|
# the host is already `docker login`ed to the registry (persistent cred in
|
|
|
|
|
# ~/.docker/config.json), so an unconditional login with an empty token would
|
|
|
|
|
# abort the deploy under `set -e`. Use the token if present, else trust the
|
|
|
|
|
# host's existing cred; a genuine auth problem then fails loudly at `pull`.
|
|
|
|
|
if [ -n "${REGISTRY_TOKEN:-}" ]; then
|
|
|
|
|
echo "==> Logging in to the registry ($REGISTRY_HOST)"
|
|
|
|
|
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username emi --password-stdin
|
|
|
|
|
else
|
|
|
|
|
echo "==> No REGISTRY_TOKEN in env; relying on the host's existing docker login to $REGISTRY_HOST"
|
|
|
|
|
fi
|
2026-07-21 21:36:03 +00:00
|
|
|
|
2026-07-22 18:39:16 +00:00
|
|
|
echo "==> Pulling images (backend=$BACKEND_IMAGE_TAG frontend=$FRONTEND_IMAGE_TAG; rolling: ${TARGETS[*]})"
|
|
|
|
|
# Retry: build-push.yml (triggered by the same push) has no ordering guarantee
|
|
|
|
|
# against this deploy -- Forgejo Actions `needs:` only works between jobs in ONE
|
|
|
|
|
# workflow file, not across the separate build-push.yml triggered by the same
|
|
|
|
|
# event. Confirmed live: a deploy raced ahead of the push and failed with "not
|
|
|
|
|
# found". A bounded retry (~5 min) covers a normal build; a genuine problem
|
|
|
|
|
# (bad tag, registry down) still fails loudly after that.
|
2026-07-22 02:12:44 +00:00
|
|
|
pull_ok=0
|
|
|
|
|
for i in $(seq 1 30); do
|
2026-07-22 18:39:16 +00:00
|
|
|
if docker compose pull "${TARGETS[@]}"; then
|
2026-07-22 02:12:44 +00:00
|
|
|
pull_ok=1
|
|
|
|
|
break
|
|
|
|
|
fi
|
|
|
|
|
echo " pull attempt $i/30 failed (image may not be pushed yet); retrying in 10s..." >&2
|
|
|
|
|
sleep 10
|
|
|
|
|
done
|
|
|
|
|
if [ "$pull_ok" != 1 ]; then
|
|
|
|
|
echo "!! docker compose pull failed after 30 attempts" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-07-20 06:09:15 +00:00
|
|
|
|
2026-07-22 18:39:16 +00:00
|
|
|
# Roll only the target service(s). Backend schema migrations run inside its own
|
|
|
|
|
# entrypoint (alembic upgrade head) before uvicorn, so there's no separate
|
|
|
|
|
# migrate step; frontend is stateless.
|
|
|
|
|
#
|
|
|
|
|
# Single-service rolls use --no-deps so recreating backend doesn't also bounce
|
|
|
|
|
# db, and recreating frontend doesn't touch backend -- that independence is the
|
|
|
|
|
# whole point of the split. A full `all` deploy instead uses --remove-orphans,
|
|
|
|
|
# which matters when the service topology itself changes (a renamed-away
|
|
|
|
|
# service's old container would otherwise keep running and squat its port --
|
|
|
|
|
# confirmed live, this is what blocked beta's first dual-service deploy with
|
|
|
|
|
# "port is already allocated").
|
|
|
|
|
echo "==> Rolling ${TARGETS[*]}"
|
|
|
|
|
if [ "$SERVICE" = all ]; then
|
|
|
|
|
docker compose up -d --remove-orphans
|
|
|
|
|
else
|
|
|
|
|
docker compose up -d --no-deps "${TARGETS[@]}"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Persist the now-live tags so the next single-service deploy knows the
|
|
|
|
|
# sibling's real tag. Written after `up` so a failed pull never records a tag
|
|
|
|
|
# that isn't actually running.
|
|
|
|
|
mkdir -p "$(dirname "$TAGS_FILE")"
|
|
|
|
|
cat > "$TAGS_FILE" <<EOF
|
|
|
|
|
# Written by deploy.sh -- the image tag each service is currently running.
|
|
|
|
|
# Untracked (see .gitignore); lets a single-service deploy leave the other alone.
|
|
|
|
|
BACKEND_IMAGE_TAG=$BACKEND_IMAGE_TAG
|
|
|
|
|
FRONTEND_IMAGE_TAG=$FRONTEND_IMAGE_TAG
|
|
|
|
|
EOF
|
|
|
|
|
|
|
|
|
|
# Health check the service(s) we rolled: backend on 8137, frontend on 8080.
|
|
|
|
|
# (For `all`, backend's `/` serving is the readiness signal the old script used
|
|
|
|
|
# and the frontend depends_on backend anyway.)
|
|
|
|
|
declare -A HEALTH_PORTS=( [backend]=8137 [frontend]=8080 )
|
|
|
|
|
health_ok=1
|
|
|
|
|
for svc in "${TARGETS[@]}"; do
|
|
|
|
|
port="${HEALTH_PORTS[$svc]}"
|
|
|
|
|
url="http://127.0.0.1:${port}/healthz"
|
|
|
|
|
echo "==> Health check: $svc ($url)"
|
|
|
|
|
ok=0
|
|
|
|
|
for i in $(seq 1 30); do
|
|
|
|
|
if curl -fsS -o /dev/null "$url"; then ok=1; break; fi
|
|
|
|
|
sleep 1
|
|
|
|
|
done
|
|
|
|
|
if [ "$ok" = 1 ]; then
|
|
|
|
|
echo "==> OK: $svc is serving"
|
|
|
|
|
else
|
|
|
|
|
echo "!! Health check failed for $svc ($url)" >&2
|
|
|
|
|
health_ok=0
|
2026-07-11 00:29:47 +00:00
|
|
|
fi
|
|
|
|
|
done
|
2026-07-22 18:39:16 +00:00
|
|
|
|
|
|
|
|
if [ "$health_ok" != 1 ]; then
|
|
|
|
|
docker compose ps || true
|
|
|
|
|
for svc in "${TARGETS[@]}"; do docker compose logs --tail=50 "$svc" || true; done
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Post-deploy warm/IndexNow only make sense once the backend is (re)deployed --
|
|
|
|
|
# they exec inside the backend container. Skip them on a frontend-only roll.
|
|
|
|
|
if [ "$SERVICE" = backend ] || [ "$SERVICE" = all ]; then
|
|
|
|
|
warm_city_archives
|
|
|
|
|
ping_indexnow
|
|
|
|
|
fi
|
|
|
|
|
exit 0
|