Docs pass after the app-repo archive; sync key-gaps skill; add CLAUDE.md (#5)

This commit is contained in:
emi 2026-07-22 23:36:21 +00:00
parent 5fd96552f7
commit e929f4606f
6 changed files with 113 additions and 25 deletions

View file

@ -55,17 +55,24 @@ The `Cross-environment drift` section should report **none** for tracked keys.
`THERMOGRAPH_VAPID_PRIVATE_KEY/_PUBLIC_KEY`). Missing here is the worst case: the app
silently mints a new value on boot, invalidating every login session and push
subscription. Fix before deploying.
- **MISSING** — a required secret is absent (`POSTGRES_PASSWORD`, `THERMOGRAPH_METRICS_TOKEN`,
`REGISTRY_TOKEN`).
- **MISSING** — a required secret is absent (`POSTGRES_PASSWORD`, `REGISTRY_TOKEN`).
- **Feature GAP** — a feature is *partially* configured (e.g. `discord-account-linking`
has the app id but not the client secret), so it's silently broken. A fully-unset
feature is "off", not a gap.
- **Dependent-key GAP** — an optional key is set but the *other* key it needs to do
anything isn't (e.g. `THERMOGRAPH_DISCORD_WEATHER_CHANNEL` set with no
`THERMOGRAPH_DISCORD_BOT_TOKEN` — discord.py gates the post on both). Asymmetric,
unlike a feature group: the prerequisite key is fine set alone (the bot token alone
already enables DMs).
- **drift** — a tracked key is in some environments but not others; often intentional,
but worth a glance.
## Keeping it current
The secret manifest (which keys are required / self-generating / grouped into features)
lives at the top of `key_gaps.py` in `REQUIRED`, `OPTIONAL_SELF_GEN`, `FEATURE_GROUPS`,
and `STANDALONE_OPTIONAL`. When a new `THERMOGRAPH_*` credential is added to
`deploy/thermograph.env.example` / `deploy/secrets/`, add it there too.
The secret manifest (which keys are required / self-generating / grouped into features /
dependent on another key) lives at the top of `key_gaps.py` in `REQUIRED`,
`OPTIONAL_SELF_GEN`, `FEATURE_GROUPS`, `DEPENDENT_OPTIONAL`, and `STANDALONE_OPTIONAL`.
When a new `THERMOGRAPH_*` credential is added to `deploy/thermograph.env.example` /
`deploy/secrets/`, add it there too — check the code path that reads it (`os.environ.get`
call site) to see whether it's truly required, self-generating, part of an all-or-nothing
feature, or only meaningful alongside another key, rather than guessing from the name.

View file

@ -8,6 +8,9 @@ Reports, per environment and across them:
- Required gaps: a required secret is missing.
- Feature gaps: a feature is *partially* configured (some keys present, some not),
so it's silently broken. Fully-unset features are "off", not a gap.
- Dependent-key gaps: an optional key is set but the OTHER key it needs to do
anything isn't (e.g. a Discord channel id with no bot token) — asymmetric,
unlike feature gaps: the prerequisite is fine set alone.
- Drift: a key present in one environment but absent in another.
It reads only KEY NAMES, never values safe to run anywhere. Sources per environment:
@ -35,7 +38,6 @@ REQUIRED = {
"THERMOGRAPH_AUTH_SECRET": True,
"THERMOGRAPH_VAPID_PRIVATE_KEY": True,
"THERMOGRAPH_VAPID_PUBLIC_KEY": True,
"THERMOGRAPH_METRICS_TOKEN": False,
"REGISTRY_TOKEN": False,
}
OPTIONAL_SELF_GEN = {
@ -46,11 +48,22 @@ FEATURE_GROUPS = {
"discord-account-linking": ["THERMOGRAPH_DISCORD_APP_ID", "THERMOGRAPH_DISCORD_CLIENT_SECRET"],
"mail-smtp": ["THERMOGRAPH_SMTP_USER", "THERMOGRAPH_SMTP_PASSWORD"],
}
# Optional keys that only do anything when a specific OTHER key is also set. Unlike
# FEATURE_GROUPS the relationship is asymmetric: the prerequisite is fine set alone
# (THERMOGRAPH_DISCORD_BOT_TOKEN alone already enables DMs), but the dependent key
# alone is a silent no-op (discord.py gates both channel posts on
# `bool(BOT_TOKEN and <channel>)`) — flagging it as a symmetric feature group would
# false-positive on every environment that has the bot token for DMs only.
DEPENDENT_OPTIONAL = {
"THERMOGRAPH_DISCORD_WEATHER_CHANNEL": "THERMOGRAPH_DISCORD_BOT_TOKEN",
"THERMOGRAPH_DISCORD_SUBSCRIPTION_CHANNEL": "THERMOGRAPH_DISCORD_BOT_TOKEN",
}
# Standalone optional keys: reported present/absent, never a "gap" on their own.
STANDALONE_OPTIONAL = [
"THERMOGRAPH_METRICS_TOKEN", # ops metrics remote access (else loopback-only, not broken)
"THERMOGRAPH_DISCORD_WEBHOOK", # daily post
"THERMOGRAPH_DISCORD_PUBLIC_KEY", # slash-command interactions
"THERMOGRAPH_DISCORD_BOT_TOKEN", # bot DMs / gateway bot
"THERMOGRAPH_DISCORD_BOT_TOKEN", # bot DMs / gateway bot (prerequisite for the two below)
]
C = {"red": "\033[31m", "yellow": "\033[33m", "green": "\033[32m",
@ -91,7 +104,7 @@ def audit(envs: dict[str, set[str]]) -> int:
for n in names:
print(f" {C['dim']}{n}: {len(envs[n])} keys{C['off']}")
print()
critical = required = feature = 0
critical = required = feature = dependent = 0
def present(env, key): # noqa: ANN001
return key in envs[env]
@ -133,6 +146,19 @@ def audit(envs: dict[str, set[str]]) -> int:
print(f" {C['red']}GAP {C['off']} {feat} @ {n}: has {have}, MISSING {miss}")
print()
# Dependent optional — the key only does anything with its prerequisite also set
print(f"{C['bold']}Dependent optional keys{C['off']}")
for key, prereq in DEPENDENT_OPTIONAL.items():
for n in names:
if not present(n, key):
print(f" {C['dim']}off {key} @ {n} (unset){C['off']}")
elif present(n, prereq):
print(f" {C['green']}OK{C['off']} {key} @ {n} (enabled, {prereq} set)")
else:
dependent += 1
print(f" {C['red']}GAP {C['off']} {key} @ {n}: set but {prereq} is missing — silently no-ops")
print()
# Standalone optional
print(f"{C['bold']}Optional keys{C['off']}")
for key in STANDALONE_OPTIONAL:
@ -150,6 +176,7 @@ def audit(envs: dict[str, set[str]]) -> int:
where = [n for n in names if key in envs[n]]
if len(where) != len(names) and (key in REQUIRED or key in OPTIONAL_SELF_GEN
or any(key in g for g in FEATURE_GROUPS.values())
or key in DEPENDENT_OPTIONAL
or key in STANDALONE_OPTIONAL):
drift = True
absent = [n for n in names if n not in where]
@ -158,10 +185,10 @@ def audit(envs: dict[str, set[str]]) -> int:
print(f" {C['green']}none{C['off']} (tracked keys are consistent)")
print()
total = critical + required + feature
total = critical + required + feature + dependent
color = C["red"] if total else C["green"]
print(f"{color}{C['bold']}Summary: {critical} critical, {required} required, "
f"{feature} feature gaps{C['off']}")
f"{feature} feature gaps, {dependent} dependent-key gaps{C['off']}")
return 1 if (critical or required) else 0

42
CLAUDE.md Normal file
View file

@ -0,0 +1,42 @@
# thermograph-infra — agent instructions
How and where the already-built Thermograph app images run. This repo owns
Terraform, the SOPS+age secrets vault, compose files, deploy scripts, host
provisioning, and the ops cron (DB backup + IndexNow). The app repos
(`thermograph-backend`, `thermograph-frontend`) own building and testing the
images; this repo never checks out app source. The old monorepo
(`emi/thermograph`) is archived — do not point anything at it.
## The four machines
Same as ACCESS.md: **dev machine** (operator's box, LAN dev server, CI runner),
**prod** (`169.58.46.181`, thermograph.org, `agent` user, passwordless sudo),
**beta** (`75.119.132.91`, beta.thermograph.org + Forgejo, `agent` user). Hosts'
`/opt/thermograph` (and LAN's `~/thermograph-dev`) are checkouts of THIS repo.
## Deploys
- `deploy/deploy.sh` — the one deploy path (prod/beta): resets this repo's
checkout, renders secrets from the SOPS vault, pulls the per-service image
tags (`BACKEND_IMAGE_TAG`/`FRONTEND_IMAGE_TAG`, persisted in untracked
`deploy/.image-tags.env`), rolls the target service, health-checks via
container healthchecks. Invoked over SSH by the app repos' deploy workflows.
- `deploy/deploy-dev.sh` — thin LAN-dev wrapper around deploy.sh (dev compose
overlay, `~/thermograph-dev`, branch `dev`).
- Branch model: see README — infra `main` is live on prod+beta, `dev` on LAN;
`release` is currently unused. App code is env-staged via image tags; infra
is not.
## Rules
- **Never run `terraform apply` casually** — no tfstate exists anywhere (never
persisted), so an apply would attempt full re-provisioning of live hosts.
Terraform here is executable documentation until state is bootstrapped.
- Secrets: only via the SOPS vault (`deploy/secrets/*.yaml`, `sops edit` +
commit + deploy). Never hand-edit `/etc/thermograph.env` on a SOPS-enabled
host — it's a rendered artifact. `secrets-guard` CI rejects plaintext.
- The ops cron (`.forgejo/workflows/ops-cron.yml`) is THE prod backup — it uses
this repo's `PROD_SSH_*` Actions secrets. If you touch it, verify a dump
actually lands in `agent@prod:~/thermograph-backups/`.
- Commits/PRs: concise and technical; never mention AI/assistants/automated
authorship.

View file

@ -26,3 +26,21 @@ The app's own source, `Dockerfile`, and build/test CI stay in the app repo —
this repo never checks out app source; hosts only pull tagged images from the
registry. See `ACCESS.md` for host access and the Swarm/Forgejo topology, and
`terraform/README.md` for the day-to-day `plan`/`apply` workflow.
## Branches & how changes reach each environment
- **`main`** — what **prod and beta** run: their `/opt/thermograph` checkouts
`git reset --hard origin/main` at the start of every deploy (`deploy/deploy.sh`).
A merge to `main` reaches those hosts on the next app deploy (or a by-hand
`deploy.sh` run); there is no separate infra deploy trigger.
- **`dev`** — what **LAN dev** runs: `~/thermograph-dev` resets to it via
`deploy/deploy-dev.sh`. Keep it fast-forwarded to `main` (infra changes are not
environment-staged today; the branches exist so LAN dev *can* trail or lead
when needed).
- **`release`** — currently consumed by nothing (prod tracks `main`, not
`release`). It exists to mirror the app repos' dev→main→release promotion
shape if per-environment infra staging is ever wanted; until then, treat
`main` as live-everywhere.
Note the asymmetry with the app repos: app code IS environment-staged
(dev→main→release maps to LAN→beta→prod via image tags), infra is not.

View file

@ -2,19 +2,13 @@
# LAN-dev deploy: roll the per-service registry-pull stack (see deploy.sh) onto
# the ~/thermograph-dev overlay instead of prod/beta's loopback-only stack.
#
# *** PREREQUISITE -- READ BEFORE RUNNING ***
# This script assumes $APP_DIR is a checkout of THIS repo (thermograph-infra),
# the same way /opt/thermograph is on prod/beta. As of this writing, the LAN
# dev box's ~/thermograph-dev is still provisioned by deploy/provision-dev-lan.sh
# cloning the OLD MONOREPO (thermograph.git, see its REPO_URL default) and running
# the monorepo's build-in-place deploy/deploy-dev.sh -- an app-code checkout with
# Dockerfiles, not an infra checkout with compose files. Until provision-dev-lan.sh
# (and its REPO_URL) is repointed at thermograph-infra and ~/thermograph-dev is
# reprovisioned from that, THIS SCRIPT IS INERT: pointing it at the live dev box
# today would `git fetch`/`reset` the wrong repository entirely. Do not treat this
# as a live deploy path until that reprovision happens -- it exists now so the infra
# repo carries its own dev-deploy story from day one, same as it already carries
# deploy.sh for prod/beta.
# the same way /opt/thermograph is on prod/beta. That is the live state: the LAN
# box's ~/thermograph-dev was reprovisioned as an infra checkout during the
# 2026-07-22 cutover (the app monorepo is archived), provision-dev-lan.sh's
# REPO_URL defaults to this repo, and the app repos' deploy-dev.yml workflows
# invoke this script on the thermograph-lan runner. This IS the live LAN-dev
# deploy path.
#
# Design: a thin wrapper around deploy.sh, not a duplicate. deploy.sh already owns
# the entire per-service registry-pull mechanism -- secrets sourcing, docker login,
@ -30,7 +24,7 @@
# from prod/beta (not just "different files/directory"), promote this to a
# standalone script at that point rather than growing special cases into deploy.sh.
#
# Usage (once the prerequisite above is satisfied), mirrors deploy.sh directly:
# Usage, mirrors deploy.sh directly:
# # roll just the backend onto a dev-tagged image:
# ssh dev-box 'SERVICE=backend BACKEND_IMAGE_TAG=sha-<12hex> deploy/deploy-dev.sh'
# # bring the whole dev stack up (both tags required, same as deploy.sh):

View file

@ -9,7 +9,7 @@
set -euo pipefail
APP_DIR="${APP_DIR:-$HOME/thermograph-dev}"
REPO_URL="${REPO_URL:-http://10.10.0.2:3080/emi/thermograph.git}"
REPO_URL="${REPO_URL:-http://10.10.0.2:3080/emi/thermograph-infra.git}"
BRANCH="${BRANCH:-dev}"
here="$(cd "$(dirname "$0")" && pwd)"