From e929f4606f396d5b6f5462623ae67cb53309e918 Mon Sep 17 00:00:00 2001 From: emi Date: Wed, 22 Jul 2026 23:36:21 +0000 Subject: [PATCH] Docs pass after the app-repo archive; sync key-gaps skill; add CLAUDE.md (#5) --- .claude/skills/key-gaps/SKILL.md | 19 ++++++++----- .claude/skills/key-gaps/key_gaps.py | 37 +++++++++++++++++++++---- CLAUDE.md | 42 +++++++++++++++++++++++++++++ README.md | 18 +++++++++++++ deploy/deploy-dev.sh | 20 +++++--------- deploy/provision-dev-lan.sh | 2 +- 6 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 CLAUDE.md diff --git a/.claude/skills/key-gaps/SKILL.md b/.claude/skills/key-gaps/SKILL.md index 7245b34..3896d72 100644 --- a/.claude/skills/key-gaps/SKILL.md +++ b/.claude/skills/key-gaps/SKILL.md @@ -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. diff --git a/.claude/skills/key-gaps/key_gaps.py b/.claude/skills/key-gaps/key_gaps.py index f8fdf6d..d5bdc8a 100755 --- a/.claude/skills/key-gaps/key_gaps.py +++ b/.claude/skills/key-gaps/key_gaps.py @@ -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 )`) — 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e10bb85 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 48f8e47..d6f2e55 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/deploy/deploy-dev.sh b/deploy/deploy-dev.sh index a741f28..83de807 100755 --- a/deploy/deploy-dev.sh +++ b/deploy/deploy-dev.sh @@ -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): diff --git a/deploy/provision-dev-lan.sh b/deploy/provision-dev-lan.sh index 1e976bc..2daaf21 100755 --- a/deploy/provision-dev-lan.sh +++ b/deploy/provision-dev-lan.sh @@ -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)"