Compare commits

..

1 commit

Author SHA1 Message Date
Emi Griffith
a981729417 secrets: configure Centralis Google sign-in for prod
Centralis has shipped the OAuth 2.1 authorization server since b194603, but
prod ran with none of it configured, so the connector could only offer a
bearer token. buildOAuth() needs a Google client AND a team list; this adds
both, plus the Workspace domain.

CENTRALIS_TEAM is an allowlist of addresses, not a domain or group rule --
a rule keyed on the domain grants production reads to anyone who can get an
address in it, and the group equivalent moves that authority to whoever
administers the group. Four addresses, listed:

  emi.g      owner    jin.j        owner
  klaudia.j  member   violet.g     member

Roles are real gates. owner is everything, including run_on_host, secrets_*,
promote and SQL writes; member is the estate's reads plus the writes that
become PRs. Identity is re-resolved from this value on every request rather
than trusted from the token, so removing a line and restarting revokes
someone at once.

The audit subjects these derive to -- emi.g, jin.j -- deliberately differ
from the CENTRALIS_TOKENS subjects emi and jin. The token registry is for
machines now, so a distinct subject is what makes an audit line answer
whether a person or a cron job did something.

CENTRALIS_GOOGLE_HOSTED_DOMAIN is set because every address is in the
Workspace. It is checked server-side as well as hinted to Google, so it
narrows who can reach the allowlist; it never populates it.

Verified before commit: the vault renders 13 keys that survive
`set -a; . <file>` byte-for-byte, and the rendered CENTRALIS_TEAM parses to
exactly the four members and capabilities above.
2026-08-01 15:42:20 -07:00
64 changed files with 211 additions and 2095 deletions

View file

@ -71,30 +71,20 @@ correct" rather than erroring.
is a divergence to reconcile deliberately, not to overwrite. is a divergence to reconcile deliberately, not to overwrite.
2. **`dev` is the default branch.** 2. **`dev` is the default branch.**
`forge_repo_settings(apply_flow_defaults=true)` — sets the default branch and `forge_repo_settings(apply_flow_defaults=true)` — sets the default branch and
permits the merge styles the chain uses, plus rebase-updates (without which permits every merge style the chain uses, including `fast-forward-only`, plus
the merge queue's core primitive 403s). rebase-updates (without which the merge queue's core primitive 403s).
The chain uses exactly two: **squash** for `feat/*``dev`, and
**fast-forward-only** for both promotions. Merge commits and rebase merges
are disabled deliberately — see the merge-style table in the root
`CLAUDE.md`. If `apply_flow_defaults` re-enables them, that is the tool
disagreeing with the policy, not permission to use them.
3. **Protection.** `forge_branch_protection(branch="dev", apply=true)`, then 3. **Protection.** `forge_branch_protection(branch="dev", apply=true)`, then
`main`, then `release`. `main`, then `release`.
**All three** branches get the required check. `release` was the exception `dev` and `main` get the required check; **`release` does not**, and that is
until 2026-08-01: `pr-build.yml` fired only for PRs into `dev` and `main`, so deliberate. `pr-build.yml` fires only for PRs into `dev` and `main`, so a PR
a PR into `release` reported no status at all, and requiring a context into `release` reports no status at all — requiring a context nothing
nothing produces makes every production promotion permanently unmergeable produces would make every production promotion permanently unmergeable, with
a 405 that names no cause. This estate has already lost an afternoon to a 405 that names no cause. This estate has already lost an afternoon to
exactly that, from a single mistyped check name. exactly that, from a single mistyped check name. If you want it closed
properly, do it in this order: add `release` to `pr-build.yml`'s
It was closed in the only safe order, and that order still governs any change `on.pull_request.branches`, confirm on a real PR that the context appears,
to the context name, or a new protected branch: add the branch to *then* pass `require_checks_on_release: true`. The other order blocks prod.
`pr-build.yml`'s `on.pull_request.branches`, confirm **on a real PR** that
the context appears, *then* require it. Confirmed on #167 — the gate reported
`PR build (required check) / gate (pull_request)` — before the check was
enabled. The other order blocks prod.
4. **Stale branches.** `forge_branches()` lists every branch with its age, how 4. **Stale branches.** `forge_branches()` lists every branch with its age, how
much unmerged work it carries, and a verdict: much unmerged work it carries, and a verdict:
- `merged` — nothing on it the trunk lacks. Delete freely. - `merged` — nothing on it the trunk lacks. Delete freely.
@ -134,7 +124,7 @@ You merge **one PR into `dev` at a time**. For each:
3. `forge_pr_await(pr=N, until="checks_complete")` — checks on the **rebased** 3. `forge_pr_await(pr=N, until="checks_complete")` — checks on the **rebased**
head. The run you saw before the update was for a merge base that no longer head. The run you saw before the update was for a merge base that no longer
exists. exists.
4. `forge_pr_merge(pr=N, method="squash", delete_branch=true)`. 4. `forge_pr_merge(pr=N, method="rebase", delete_branch=true)`.
5. Only then move to the next PR. 5. Only then move to the next PR.
`block_on_outdated_branch` is set on `dev`, so Forgejo enforces step 2 rather `block_on_outdated_branch` is set on `dev`, so Forgejo enforces step 2 rather
@ -150,7 +140,7 @@ context. Do not resolve a large conflict on someone else's behalf.
Promote when `dev` is green and a coherent batch is complete — not on a timer, Promote when `dev` is green and a coherent batch is complete — not on a timer,
not mid-feature. `promote(from="dev", to="main")` opens the PR with the not mid-feature. `promote(from="dev", to="main")` opens the PR with the
divergence, the conflict prediction and the changelog written into its body. divergence, the conflict prediction and the changelog written into its body.
Merge it with `method="fast-forward-only"`. Merge it with `method="merge"`.
Unready work belongs behind a feature flag, or not on `dev` yet. **Never promote Unready work belongs behind a feature flag, or not on `dev` yet. **Never promote
around it with cherry-picks.** around it with cherry-picks.**
@ -161,23 +151,17 @@ SHAs, and merging it would fire the target's deploys for nothing.
### Promotion: `main``release` ### Promotion: `main``release`
Prepare with `promote`, then ask. On the owner's yes, merge with Prepare with `promote`, then ask. On the owner's yes, merge with `method="merge"`
`method="fast-forward-only"` and tag it: `forge_releases(create={tag:"v<semver>", target:"release", ...})`. and tag it: `forge_releases(create={tag:"v<semver>", target:"release", ...})`.
**On fast-forward.** Both promotions move by fast-forward, and as of **On fast-forward.** The written policy is that `release` moves only by
2026-08-01 that is in force rather than aspirational. It was blocked until then fast-forward. It cannot today: every promotion so far has been a merge commit
for a structural reason worth remembering: every promotion had been a merge *into* the target, which the source never receives, so the branches are mutually
commit *into* the target, which the source never receives, so the branches were divergent by construction and Forgejo will refuse a `fast-forward-only` merge —
mutually divergent by construction and Forgejo refused `fast-forward-only` correctly. The style is implemented and accepted by `forge_pr_merge`; adopting
correctly. The one-time reconciliation the old policy was waiting for has it needs a one-time reconciliation of the protected branches, which is the
happened; the trees were already identical, so it carried no content. owner's decision. Until that happens, promotions are merge commits. Do not
attempt to "fix" this with a rebase or a force-push.
`release ⊆ main ⊆ dev` now holds, and a promotion lands the branches on the
same SHA rather than merely the same tree. Merge commits and rebase merges are
disabled repo-wide, so a slip fails loudly instead of quietly re-diverging the
chain. If Forgejo refuses with "diverging branches", the target has picked up a
commit the source lacks — reconcile it once, target into source, fast-forward.
Do not attempt to "fix" it with a rebase or a force-push.
### Hotfix ### Hotfix

View file

@ -16,14 +16,9 @@ name: Build + push images (Forgejo registry)
# docker.sock automounted (Docker-outside-of-Docker, no privileged mode); the # docker.sock automounted (Docker-outside-of-Docker, no privileged mode); the
# job image (node:20-bookworm) ships no docker CLI, hence the install step. # job image (node:20-bookworm) ships no docker CLI, hence the install step.
# #
# The registry is deliberately mesh-only: dev.jinemi.com's public DNS resolves # The registry is deliberately mesh-only: git.thermograph.org's public DNS
# to vps1's public IP, whose Caddy ACL rejects /v2/ paths, so any runner host # resolves to beta's public IP, whose Caddy ACL rejects /v2/ paths, so any
# that pushes or pulls needs an /etc/hosts entry pinning it to 10.10.0.2. # runner host that pushes or pulls needs an /etc/hosts entry.
#
# The host itself comes from the Forgejo Actions variable REGISTRY_HOST, not
# from this file — a repo-side rename (git.thermograph.org -> dev.jinemi.com)
# does nothing until that variable is updated too. See
# infra/deploy/forgejo/README.md, "Migrating the domain".
on: on:
push: push:
@ -172,26 +167,6 @@ jobs:
fi fi
docker build "${tag_args[@]}" "${{ matrix.domain }}/" docker build "${tag_args[@]}" "${{ matrix.domain }}/"
# Build -> TEST -> push. Without this the only pytest run in CI was
# pr-build's, so anything reaching a branch by another route (a direct
# push, a workflow_dispatch, a v*.*.* tag) published an image that CI had
# never tested — and deploy.yml then rolls that exact tag onto beta/prod.
# Gating the artifact rather than the branch is what makes the deploy safe,
# since deploy.yml consumes a tag, not a commit status.
#
# Same invocation as build.yml's, deliberately: -u 0 because the image's
# default user cannot write to site-packages, --entrypoint sh because the
# real entrypoint is alembic-migrate-then-serve and would swallow the
# command, and `unset THERMOGRAPH_BASE` because the image bakes the prod
# root mount (/) while the tests are written against the /thermograph
# prefix. The Go daemon and Go frontend suites need no step here: both run
# inside their Dockerfiles, so `docker build` above already gated them.
- name: Test the built image (backend only)
if: steps.image.outputs.changed == 'true' && matrix.domain == 'backend'
run: |
docker run --rm -u 0 --entrypoint sh "${{ steps.image.outputs.sha_tag }}" \
-c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests -q"
- name: Push (SHA tag, every push) - name: Push (SHA tag, every push)
if: steps.image.outputs.changed == 'true' if: steps.image.outputs.changed == 'true'
run: docker push "${{ steps.image.outputs.sha_tag }}" run: docker push "${{ steps.image.outputs.sha_tag }}"

View file

@ -19,11 +19,7 @@ name: PR build (required check)
on: on:
pull_request: pull_request:
# `release` included: it is the branch that deploys to PROD, and it used to branches: [dev, main]
# be the least-checked branch in the repo. A PR into release ran neither a
# build nor a test — only the two standalone lint workflows — so the intended
# promotion path was the one with no gate on it.
branches: [dev, main, release]
concurrency: concurrency:
group: pr-build-${{ github.event.pull_request.number }} group: pr-build-${{ github.event.pull_request.number }}
@ -77,32 +73,18 @@ jobs:
if: needs.changes.outputs.observability == 'true' if: needs.changes.outputs.observability == 'true'
uses: ./.forgejo/workflows/observability-validate.yml uses: ./.forgejo/workflows/observability-validate.yml
# NOT domain-gated and NOT `needs: changes`: both are repo-wide invariants that
# must hold on every PR regardless of which directories it touches. A plaintext
# secret or a broken script can arrive in a commit that changes no app domain at
# all — precisely the case where every domain job skips and `gate` used to pass
# vacuously.
lint-shell:
uses: ./.forgejo/workflows/shell-lint.yml
guard-secrets:
uses: ./.forgejo/workflows/secrets-guard.yml
gate: gate:
name: gate name: gate
# always(): skipped domain jobs (nothing changed there) must not skip the # always(): skipped domain jobs (nothing changed there) must not skip the
# gate itself -- the required check has to report on EVERY PR. # gate itself -- the required check has to report on EVERY PR.
if: always() if: always()
needs: [changes, build-backend, build-frontend, validate-observability, needs: [changes, build-backend, build-frontend, validate-observability]
lint-shell, guard-secrets]
runs-on: docker runs-on: docker
steps: steps:
- name: Reduce domain results - name: Reduce domain results
run: | run: |
set -euo pipefail set -euo pipefail
ok=1 ok=1
# Domain jobs: `skipped` is a pass — it means the PR did not touch that
# domain, which is the whole point of the path filtering.
for pair in \ for pair in \
"backend=${{ needs.build-backend.result }}" \ "backend=${{ needs.build-backend.result }}" \
"frontend=${{ needs.build-frontend.result }}" \ "frontend=${{ needs.build-frontend.result }}" \
@ -113,17 +95,4 @@ jobs:
*) ok=0 ;; *) ok=0 ;;
esac esac
done done
# Repo-wide invariants: `skipped` is a FAILURE. These have no `if:` and [ "$ok" = 1 ] || { echo "a changed domain's check failed"; exit 1; }
# no `needs:`, so they run on every PR; a skip means the job did not
# execute and the invariant is therefore unverified, which must not be
# reported as a pass.
for pair in \
"shellcheck=${{ needs.lint-shell.result }}" \
"secrets-encrypted=${{ needs.guard-secrets.result }}"; do
echo "$pair"
case "${pair#*=}" in
success) ;;
*) ok=0 ;;
esac
done
[ "$ok" = 1 ] || { echo "a required check failed"; exit 1; }

View file

@ -11,11 +11,6 @@ name: secrets-guard
# runs when you expect it to isn't a backstop. # runs when you expect it to isn't a backstop.
on: on:
# workflow_call so pr-build.yml can fold this into the single required `gate`
# status. This is the one that mattered most: branch protection requires only
# `gate`, so a commit adding a PLAINTEXT secrets file failed this check as a
# separate status and was still mergeable.
workflow_call: {}
pull_request: pull_request:
push: push:
branches: [dev, main, release] branches: [dev, main, release]

View file

@ -20,11 +20,6 @@ name: shell-lint
# to fix any new findings in the same PR as the bump. # to fix any new findings in the same PR as the bump.
on: on:
# workflow_call so pr-build.yml can fold this into the single required `gate`
# status. It stays independently triggered as well: branch protection is
# configured to require only `gate`, so before this was reachable from there a
# shellcheck regression reported as a separate status that nothing enforced.
workflow_call: {}
pull_request: pull_request:
push: push:
branches: [dev, main, release] branches: [dev, main, release]

View file

@ -22,21 +22,6 @@ every change, so a stale one is a correctness bug, not a documentation bug.
`dev`, `main` and `release` are protected: **everything is a PR**, for humans and `dev`, `main` and `release` are protected: **everything is a PR**, for humans and
agents alike. Promotion is one PR per hop, `dev``main``release`. agents alike. Promotion is one PR per hop, `dev``main``release`.
**Merge style is part of the contract**, and Forgejo enforces it repo-wide —
it cannot scope styles per branch, so these are the only two enabled:
| Hop | Style |
|---|---|
| `feat/*``dev` | **squash** |
| `dev``main`, `main``release` | **fast-forward only** |
Merge commits and rebase merges are disabled. A promotion therefore never
creates a commit, which keeps `release ⊆ main ⊆ dev` true permanently: the
three branches hold identical history, never merely identical trees. Squashing
into `main` instead would strand `merge-base(dev, main)` and make every later
promotion re-apply work already there. Rebase-*updates* stay enabled — the
merge queue 403s without them.
`.claude/BRANCHING.md` is the orchestrator's runbook for that flow — the `.claude/BRANCHING.md` is the orchestrator's runbook for that flow — the
serialised merge queue, promotions, hotfix down-merges — and serialised merge queue, promotions, hotfix down-merges — and
`.claude/ownership.md` is the module map saying which tasks may run `.claude/ownership.md` is the module map saying which tasks may run

View file

@ -121,7 +121,7 @@ Checked every split-repo branch by dry-run merge; migrated everything real:
1. Create `Jinemi/thermograph` on Forgejo (a **new** repo — do not resurrect the 1. Create `Jinemi/thermograph` on Forgejo (a **new** repo — do not resurrect the
archived monorepo) and push `main`. archived monorepo) and push `main`.
2. Actions config, once: secrets `REGISTRY_TOKEN`, `SSH_HOST/USER/KEY/PORT` 2. Actions config, once: secrets `REGISTRY_TOKEN`, `SSH_HOST/USER/KEY/PORT`
(beta), `PROD_SSH_*` (prod); variable `REGISTRY_HOST=dev.jinemi.com`. (beta), `PROD_SSH_*` (prod); variable `REGISTRY_HOST=git.thermograph.org`.
3. Branch protection on `dev` + `main`: required check `gate`, squash merges, 3. Branch protection on `dev` + `main`: required check `gate`, squash merges,
auto-merge on green. auto-merge on green.
4. Hosts (beta, then prod): clone the monorepo to `/opt/thermograph.new`; 4. Hosts (beta, then prod): clone the monorepo to `/opt/thermograph.new`;

View file

@ -15,17 +15,6 @@ WORKDIR /src
COPY daemon/go.mod daemon/go.sum ./ COPY daemon/go.mod daemon/go.sum ./
RUN go mod download RUN go mod download
COPY daemon/ ./ COPY daemon/ ./
# gofmt + vet + the full daemon test suite BEFORE the build, so no published
# image can contain a daemon that failed its own tests — the same guarantee
# frontend/Dockerfile already gives the Go frontend, and the reason the tests
# live here rather than in a workflow step: build-push.yml builds this image on
# every push to dev/main/release with no test job of its own, so a check that is
# not part of `docker build` does not gate the artifact that actually deploys.
#
# These 48 tests (gateway, cron, apiclient, config) previously ran nowhere:
# `grep -rn "go test" .forgejo/workflows/` found nothing. The daemon owns the
# prod Discord gateway websocket and every recurring-job timer.
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
# -trimpath keeps the build reproducible (identical sources -> identical # -trimpath keeps the build reproducible (identical sources -> identical
# binary), which is what lets the final stage's COPY layer cache-hit when only # binary), which is what lets the final stage's COPY layer cache-hit when only
# Python code changed. # Python code changed.

View file

@ -1,104 +0,0 @@
"""/api/v2/narrative — serve the precomputed plain-English summary for one day.
This endpoint is **read-only with respect to the model**. It never calls Ollama,
never blocks on one, and has no code path that could. It reads one row from the
derived store and returns it. That is the entire point of the design: inference
runs on the operator's desktop on a batch cadence
(``scripts/generate_narratives.py``), so thermograph.org keeps answering at
database speed whether or not that machine is powered on, whether or not the
WireGuard mesh is up, and whether or not a GPU is currently present in it.
A cache miss is an ordinary, expected outcome a cell nobody has generated for
yet, or a day beyond what the last batch covered. It answers 200 with
``narrative: null`` and ``status: "pending"`` rather than 404, because "no
sentence yet" is not a client error and a frontend should render the page
without the sentence rather than treat the response as a failure.
"""
import datetime
import hashlib
import json
import os
from fastapi import APIRouter, HTTPException, Query, Request, Response
from data import grid
from data import narrator
from data import store
router = APIRouter(tags=["narrative"])
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
def _parse_date(value: str | None) -> datetime.date:
"""Target date, defaulting to today. Mirrors web/app.py's _parse_target_date
contract (400 on an unparseable date rather than a silent fallback)."""
if not value:
return datetime.date.today()
try:
return datetime.date.fromisoformat(value)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
def _etag_for(cell_id: str, key: str, token: str, stamp) -> str:
"""Weak ETag over the row's identity *and* its generation stamp.
Folding the stamp in is what makes a pending response safely cacheable: while
the narrative is missing the tag is stable (so revalidation is a cheap 304),
and the moment the batch job writes the row the stamp changes, the tag
changes, and the client gets the sentence on its next revalidate."""
raw = f"{cell_id}:{key}:{token}:{stamp if stamp is not None else 'pending'}"
return f'W/"{hashlib.sha1(raw.encode()).hexdigest()[:20]}"'
def _not_modified(request: Request, etag: str) -> bool:
inm = request.headers.get("if-none-match")
if not inm:
return False
tags = {t.strip() for t in inm.split(",")}
return "*" in tags or etag in tags or etag.removeprefix("W/") in tags
def api_narrative(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
):
"""The stored one-sentence summary of how unusual this day was here.
Percentile-relative, like every other Thermograph surface: it describes where
the day sits against ~45 years of this location's own history, and by
construction contains no temperature or rainfall figure (see data/narrator.py
the model is never given one). Not a forecast.
"""
target = _parse_date(date)
cell = grid.snap(lat, lon)
key = narrator.narrative_key(target)
token = narrator.narrative_token()
row = store.get_narrative(cell["id"], key, token)
etag = _etag_for(cell["id"], key, token, (row or {}).get("generated_at"))
if _not_modified(request, etag):
return Response(status_code=304, headers={"ETag": etag})
payload = {
"cell": {"id": cell["id"],
"center_lat": cell["center_lat"],
"center_lon": cell["center_lon"]},
"date": target.isoformat(),
"narrative": row["text"] if row else None,
"status": "ready" if row else "pending",
"model": row["model"] if row else None,
"generated_at": row["generated_at"] if row else None,
}
return Response(
content=json.dumps(payload, separators=(",", ":")).encode(),
media_type="application/json",
headers={"ETag": etag},
)
router.add_api_route("/narrative", api_narrative, methods=["GET"])

View file

@ -47,23 +47,6 @@ type Config struct {
// DiscordToken is the bot token; only meaningful when DiscordEnabled. // DiscordToken is the bot token; only meaningful when DiscordEnabled.
DiscordToken string DiscordToken string
// DiscordGuildMentions gates whether an @mention in a *server channel* gets
// a grade card from us. On by default — that is the behaviour this daemon
// shipped with.
//
// It exists because the same bot identity can be answered for from two
// places. The operator's desktop runs a conversational agent that replies as
// this account (see the discord-voice-bot repo, AGENTS.md); it grades through
// the same API we do, and can also hold a conversation. With both live, one
// mention gets two answers. Setting THERMOGRAPH_DISCORD_BOT_MENTIONS to a
// falsey value hands the channel surface to that agent.
//
// DMs are unaffected and always answered here: the agent polls guild
// channels only, so a DM has no other responder. The /grade slash command is
// a separate HTTP path and keeps working either way — which matters, because
// it is then what still answers while the desktop is asleep.
DiscordGuildMentions bool
WarmCitiesInterval time.Duration WarmCitiesInterval time.Duration
IndexNowInterval time.Duration IndexNowInterval time.Duration
} }
@ -78,17 +61,6 @@ func truthy(s string) bool {
return false return false
} }
// truthyDefault is truthy for a knob that has a non-false default: unset (or
// whitespace) keeps the default, anything else is read normally. Distinguishing
// "unset" from "set to something falsey" is the whole point — plain truthy()
// would silently read an absent var as off.
func truthyDefault(s string, def bool) bool {
if strings.TrimSpace(s) == "" {
return def
}
return truthy(s)
}
// intervalHours parses an interval env var. A malformed or non-positive value // intervalHours parses an interval env var. A malformed or non-positive value
// falls back to the default rather than erroring — mirrors the Python // falls back to the default rather than erroring — mirrors the Python
// `int(os.environ.get(..., "24") or 24)` behaviour where a bad knob degrades // `int(os.environ.get(..., "24") or 24)` behaviour where a bad knob degrades
@ -130,9 +102,6 @@ func load(getenv func(string) string) (*Config, error) {
// The gateway needs both the opt-in flag and a token; missing either just // The gateway needs both the opt-in flag and a token; missing either just
// disables it — the cron half is still worth running on its own. // disables it — the cron half is still worth running on its own.
cfg.DiscordEnabled = truthy(getenv("THERMOGRAPH_DISCORD_BOT")) && cfg.DiscordToken != "" cfg.DiscordEnabled = truthy(getenv("THERMOGRAPH_DISCORD_BOT")) && cfg.DiscordToken != ""
// Defaults on when unset: this is a switch for turning existing behaviour
// off, so an env file that has never heard of it must keep working.
cfg.DiscordGuildMentions = truthyDefault(getenv("THERMOGRAPH_DISCORD_BOT_MENTIONS"), true)
cfg.WarmCitiesInterval = intervalHours(getenv("THERMOGRAPH_WARM_CITIES_INTERVAL_HOURS"), DefaultWarmCitiesIntervalHours) cfg.WarmCitiesInterval = intervalHours(getenv("THERMOGRAPH_WARM_CITIES_INTERVAL_HOURS"), DefaultWarmCitiesIntervalHours)
cfg.IndexNowInterval = intervalHours(getenv("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS"), DefaultIndexNowIntervalHours) cfg.IndexNowInterval = intervalHours(getenv("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS"), DefaultIndexNowIntervalHours)

View file

@ -62,34 +62,6 @@ func TestTruthyParsing(t *testing.T) {
} }
} }
func TestGuildMentionsDefaultsOn(t *testing.T) {
// This knob turns existing behaviour off, so every env file that predates
// it — which is all of them — must keep answering mentions.
cases := []struct {
raw string
want bool
}{
{"", true}, // unset means the default, not "off"
{" ", true}, // whitespace is still unset
{"1", true},
{"true", true},
{"on", true},
{"0", false},
{"false", false},
{"no", false},
{"off", false},
}
for _, c := range cases {
cfg, err := load(env(map[string]string{"THERMOGRAPH_DISCORD_BOT_MENTIONS": c.raw}))
if err != nil {
t.Fatalf("raw=%q: unexpected error %v", c.raw, err)
}
if cfg.DiscordGuildMentions != c.want {
t.Errorf("raw=%q: DiscordGuildMentions=%v, want %v", c.raw, cfg.DiscordGuildMentions, c.want)
}
}
}
func TestIntervalDefaultsAndFallbacks(t *testing.T) { func TestIntervalDefaultsAndFallbacks(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View file

@ -48,9 +48,7 @@ import (
// cfg.DiscordEnabled — an unconfigured deploy never gets this far, so an // cfg.DiscordEnabled — an unconfigured deploy never gets this far, so an
// unconfigured deploy pays nothing. // unconfigured deploy pays nothing.
func Run(ctx context.Context, cfg *config.Config, api Grader) error { func Run(ctx context.Context, cfg *config.Config, api Grader) error {
b := New(cfg.DiscordToken, api) return New(cfg.DiscordToken, api).Run(ctx)
b.guildMentions = cfg.DiscordGuildMentions
return b.Run(ctx)
} }
// v10 JSON gateway. On a resume we reconnect to the session's // v10 JSON gateway. On a resume we reconnect to the session's
@ -146,22 +144,17 @@ type Bot struct {
sem chan struct{} // bounds concurrent MESSAGE_CREATE handlers sem chan struct{} // bounds concurrent MESSAGE_CREATE handlers
rest string // Discord REST base; swapped for a test server in tests rest string // Discord REST base; swapped for a test server in tests
http *http.Client // REST replies (not the gateway socket) http *http.Client // REST replies (not the gateway socket)
// guildMentions answers mentions in server channels. Defaults true via New;
// Run lowers it from config when the desktop agent owns that surface.
guildMentions bool
} }
// New returns a Bot that authenticates with token and answers city queries // New returns a Bot that authenticates with token and answers city queries
// through api. // through api.
func New(token string, api Grader) *Bot { func New(token string, api Grader) *Bot {
return &Bot{ return &Bot{
token: token, token: token,
api: api, api: api,
sem: make(chan struct{}, maxConcurrentReplies), sem: make(chan struct{}, maxConcurrentReplies),
rest: discordAPIBase, rest: discordAPIBase,
http: &http.Client{Timeout: 30 * time.Second}, http: &http.Client{Timeout: 30 * time.Second},
guildMentions: true,
} }
} }

View file

@ -97,12 +97,7 @@ func stripMentions(content, botID string) string {
// reply quoting the mention must not trigger another reply). The text after // reply quoting the mention must not trigger another reply). The text after
// the mention is treated as a city query; DMs are the query as-is (trimmed // the mention is treated as a city query; DMs are the query as-is (trimmed
// but not collapsed, matching the Python's .strip()). // but not collapsed, matching the Python's .strip()).
// func triage(m *gwMessage, botID string) (action, string) {
// guildMentions false keeps us silent on server-channel mentions while leaving
// DMs alone: the operator's conversational agent answers as this same identity,
// and both of us replying means one mention gets two answers. See
// config.DiscordGuildMentions for the full picture.
func triage(m *gwMessage, botID string, guildMentions bool) (action, string) {
if m.Author.Bot || string(m.Author.ID) == botID { if m.Author.Bot || string(m.Author.ID) == botID {
return actSilent, "" return actSilent, ""
} }
@ -110,9 +105,6 @@ func triage(m *gwMessage, botID string, guildMentions bool) (action, string) {
if !dm && !mentionsBot(m, botID) { if !dm && !mentionsBot(m, botID) {
return actSilent, "" return actSilent, ""
} }
if !dm && !guildMentions {
return actSilent, ""
}
var query string var query string
if dm { if dm {
query = strings.TrimSpace(m.Content) query = strings.TrimSpace(m.Content)

View file

@ -52,21 +52,21 @@ func asDM() func(*gwMessage) {
// --- when the bot stays silent ---------------------------------------------- // --- when the bot stays silent ----------------------------------------------
func TestIgnoresOtherBots(t *testing.T) { func TestIgnoresOtherBots(t *testing.T) {
act, _ := triage(testMsg("<@999> Phoenix", fromID("8"), fromBot(), mentioning(botID)), botID, true) act, _ := triage(testMsg("<@999> Phoenix", fromID("8"), fromBot(), mentioning(botID)), botID)
if act != actSilent { if act != actSilent {
t.Fatalf("bot author should be ignored, got action %d", act) t.Fatalf("bot author should be ignored, got action %d", act)
} }
} }
func TestIgnoresItsOwnMessages(t *testing.T) { func TestIgnoresItsOwnMessages(t *testing.T) {
act, _ := triage(testMsg("hello", fromID(botID), mentioning(botID)), botID, true) act, _ := triage(testMsg("hello", fromID(botID), mentioning(botID)), botID)
if act != actSilent { if act != actSilent {
t.Fatalf("own message should be ignored, got action %d", act) t.Fatalf("own message should be ignored, got action %d", act)
} }
} }
func TestIgnoresGuildMessageWithoutAMention(t *testing.T) { func TestIgnoresGuildMessageWithoutAMention(t *testing.T) {
act, _ := triage(testMsg("just chatting"), botID, true) act, _ := triage(testMsg("just chatting"), botID)
if act != actSilent { if act != actSilent {
t.Fatalf("unmentioned guild message should be ignored, got action %d", act) t.Fatalf("unmentioned guild message should be ignored, got action %d", act)
} }
@ -75,21 +75,21 @@ func TestIgnoresGuildMessageWithoutAMention(t *testing.T) {
// --- when the bot replies ---------------------------------------------------- // --- when the bot replies ----------------------------------------------------
func TestMentionWithCityGrades(t *testing.T) { func TestMentionWithCityGrades(t *testing.T) {
act, query := triage(testMsg("<@999> Phoenix", mentioning(botID)), botID, true) act, query := triage(testMsg("<@999> Phoenix", mentioning(botID)), botID)
if act != actGrade || query != "Phoenix" { if act != actGrade || query != "Phoenix" {
t.Fatalf("want grade %q, got action %d query %q", "Phoenix", act, query) t.Fatalf("want grade %q, got action %d query %q", "Phoenix", act, query)
} }
} }
func TestLegacyNicknameMentionIsStripped(t *testing.T) { func TestLegacyNicknameMentionIsStripped(t *testing.T) {
act, query := triage(testMsg("<@!999> Tokyo", mentioning(botID)), botID, true) act, query := triage(testMsg("<@!999> Tokyo", mentioning(botID)), botID)
if act != actGrade || query != "Tokyo" { if act != actGrade || query != "Tokyo" {
t.Fatalf("want grade %q, got action %d query %q", "Tokyo", act, query) t.Fatalf("want grade %q, got action %d query %q", "Tokyo", act, query)
} }
} }
func TestDMNeedsNoMention(t *testing.T) { func TestDMNeedsNoMention(t *testing.T) {
act, query := triage(testMsg("Berlin", asDM()), botID, true) act, query := triage(testMsg("Berlin", asDM()), botID)
if act != actGrade || query != "Berlin" { if act != actGrade || query != "Berlin" {
t.Fatalf("want grade %q, got action %d query %q", "Berlin", act, query) t.Fatalf("want grade %q, got action %d query %q", "Berlin", act, query)
} }
@ -98,39 +98,14 @@ func TestDMNeedsNoMention(t *testing.T) {
func TestDMTrimsButKeepsInnerWhitespace(t *testing.T) { func TestDMTrimsButKeepsInnerWhitespace(t *testing.T) {
// Parity with the Python DM path (.strip(), not a collapse): only the // Parity with the Python DM path (.strip(), not a collapse): only the
// edges are trimmed. // edges are trimmed.
act, query := triage(testMsg(" New York ", asDM()), botID, true) act, query := triage(testMsg(" New York ", asDM()), botID)
if act != actGrade || query != "New York" { if act != actGrade || query != "New York" {
t.Fatalf("want grade %q, got action %d query %q", "New York", act, query) t.Fatalf("want grade %q, got action %d query %q", "New York", act, query)
} }
} }
// --- handing the channel surface to the desktop agent ------------------------
func TestGuildMentionsOffStaysSilentInChannels(t *testing.T) {
// With the conversational agent live, a grade card here would be the second
// answer to the same mention.
act, _ := triage(testMsg("<@999> Phoenix", mentioning(botID)), botID, false)
if act != actSilent {
t.Fatalf("guild mention should be silent when the agent owns it, got action %d", act)
}
// The bare-mention help line is a reply too, and would double up the same way.
act, _ = triage(testMsg("<@999>", mentioning(botID)), botID, false)
if act != actSilent {
t.Fatalf("bare guild mention should be silent too, got action %d", act)
}
}
func TestGuildMentionsOffStillAnswersDMs(t *testing.T) {
// The agent polls guild channels only, so a DM has no other responder —
// staying silent here would just drop the message.
act, query := triage(testMsg("Berlin", asDM()), botID, false)
if act != actGrade || query != "Berlin" {
t.Fatalf("DMs must keep working, got action %d query %q", act, query)
}
}
func TestMentionWithNoQueryGivesHelp(t *testing.T) { func TestMentionWithNoQueryGivesHelp(t *testing.T) {
act, query := triage(testMsg("<@999>", mentioning(botID)), botID, true) act, query := triage(testMsg("<@999>", mentioning(botID)), botID)
if act != actHelp || query != "" { if act != actHelp || query != "" {
t.Fatalf("want help, got action %d query %q", act, query) t.Fatalf("want help, got action %d query %q", act, query)
} }
@ -168,7 +143,7 @@ func TestSnowflakeAcceptsStringOrNumber(t *testing.T) {
if m.ID != "42" || string(m.Author.ID) != "1" { if m.ID != "42" || string(m.Author.ID) != "1" {
t.Fatalf("numeric ids mis-decoded: id=%q author=%q", m.ID, m.Author.ID) t.Fatalf("numeric ids mis-decoded: id=%q author=%q", m.ID, m.Author.ID)
} }
act, query := triage(&m, botID, true) act, query := triage(&m, botID)
if act != actGrade || query != "Lima" { if act != actGrade || query != "Lima" {
t.Fatalf("want grade %q, got action %d query %q", "Lima", act, query) t.Fatalf("want grade %q, got action %d query %q", "Lima", act, query)
} }

View file

@ -114,7 +114,7 @@ func retryAfter(h http.Header, body []byte) time.Duration {
// for. Runs in its own goroutine (see dispatch) so a slow grade lookup can // for. Runs in its own goroutine (see dispatch) so a slow grade lookup can
// never stall heartbeats or the read loop. // never stall heartbeats or the read loop.
func (b *Bot) handleMessage(ctx context.Context, m *gwMessage, botID string) { func (b *Bot) handleMessage(ctx context.Context, m *gwMessage, botID string) {
act, query := triage(m, botID, b.guildMentions) act, query := triage(m, botID)
switch act { switch act {
case actSilent: case actSilent:
case actHelp: case actHelp:

View file

@ -1,325 +0,0 @@
"""Plain-English grade narratives — a local LLM phrases one graded day.
Thermograph's payloads say a day sits in the 92nd percentile and earns the
"Very High" tier. That is precise and completely inert to read. This module
turns one graded day into a sentence a person actually absorbs, using a model
served by Ollama on the operator's desktop.
Three properties hold this together, and every one of them is load-bearing:
* **Generation never happens on the request path.** Nothing here is imported by
a route handler that has to answer quickly. ``scripts/generate_narratives.py``
fills the cache; ``api/narrative_routes.py`` only ever *reads* it. A public
endpoint therefore has no dependency on the desktop being powered on, the
mesh being up, or a model being loaded the worst case is a cache miss, which
serves ``null`` and renders as nothing.
* **The model never sees a number it could misreport.** ``facts_from_grade``
hands it tier labels and percentile ordinals only never a temperature, never
a raw reading. A model that hallucinates cannot invent "38°C" because it was
never told a degree value, and the sentence it writes is checked against
:data:`_BANNED` for units it had no business emitting. This is the whole
reason narratives are unit-free rather than rendered per-country: it removes
an entire class of wrong output, and as a bonus there is exactly one cached
narrative per (cell, date) instead of a °C and a °F variant.
* **The framing stays relative.** Thermograph grades how *unusual* weather is
against ~45 years of that exact location's history — it is not a forecast and
not a thermometer. The prompt says so, and the sanitizer drops output that
drifts into prediction or advice.
Configuration is entirely environmental, so the same code runs on the desktop
(Ollama on localhost) and on a host that reaches it across WireGuard:
* ``THERMOGRAPH_OLLAMA_URL`` default ``http://127.0.0.1:11434``
* ``THERMOGRAPH_NARRATIVE_MODEL`` default ``qwen3:14b``
* ``THERMOGRAPH_NARRATIVE_TIMEOUT`` seconds, default 120 (generous on purpose:
a CPU-only fallback is roughly an order of magnitude slower than the GPU, and
a batch job would rather wait than lose the row)
"""
import datetime
import math
import os
import re
import httpx
# Bump to invalidate every stored narrative at once — it is half of the validity
# token (the model name is the other half), so a prompt or house-style change
# here orphans the old rows rather than silently mixing two generations of text.
# n2: fact lines state the direction ("warmer than 82% of nights…") instead of a
# bare "82nd percentile (High)", which models could and did read backwards.
NARRATIVE_VER = "n2"
OLLAMA_URL = os.environ.get("THERMOGRAPH_OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/")
MODEL = os.environ.get("THERMOGRAPH_NARRATIVE_MODEL", "qwen3:14b").strip()
TIMEOUT = float(os.environ.get("THERMOGRAPH_NARRATIVE_TIMEOUT", "120"))
# Hard ceiling on what we will store. The prompt asks for one sentence; this is
# the backstop for a model that ignores it. Sentence-truncated, not chopped
# mid-word (see _sanitize).
MAX_CHARS = 240
# The metrics a narrative may mention, in prompt order: payload key, the label
# used in the prompt, and the noun for what the percentile ranks against.
# Anything absent from the graded day is simply omitted — a cell with no
# feels-like reading yields a shorter fact list, never a "None" in the prompt.
#
# The noun matters more than it looks. An earlier version of this prompt wrote
# "Overnight low: 82nd percentile (High)", which a model read as "the low was
# high" and phrased as a *cool* night — an exact inversion of the fact, and one
# no output filter can catch because nothing about the sentence looks wrong.
# Stating the direction outright ("warmer than 82% of nights…") removes the
# ambiguity at the source; the tier is then along for corroboration, not
# interpretation.
_METRICS = (
("tmax", "Daytime high", "days"),
("tmin", "Overnight low", "nights"),
("feels", "Feels-like", "days"),
("precip", "Rain", "rainy days"),
)
# Units and forecast vocabulary the model was never given and must not produce.
# Matched case-insensitively against the finished sentence; a hit voids the
# generation rather than storing something that reads authoritative and is not.
_BANNED = re.compile(
r"(\d+\s*(°|deg|celsius|fahrenheit|c\b|f\b)" # any degree reading
r"|\b(mm|cm|inches|inch|mph|km/?h|knots?)\b" # any measured quantity
r"|\b(tomorrow|forecast|expect|will be|should be)\b" # prediction
r"|\b(wear|bring|advise|recommend|stay hydrated)\b)", # advice
re.I,
)
# qwen3 is a thinking model. We pass think=False, but a reasoning block leaking
# into the response is exactly the kind of thing that changes between releases,
# so it is stripped defensively too.
_THINK = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.I | re.S)
# Models open with a conversational lead-in more often than they don't, and it
# arrives in two shapes: a bare interjection ("Sure!", "Okay,") and a labelled
# hand-off ("Here's a sentence:"), frequently both at once. Each is anchored to a
# known opener rather than to "anything before a colon", so a real narrative that
# happens to use a colon — "One thing stands out: the night was extraordinary" —
# survives untouched.
_LEADIN = re.compile(r"^\s*(?:sure|certainly|of course|okay|ok|absolutely|got it)"
r"\b[!,.…]*\s*", re.I)
_LABEL = re.compile(r"^\s*(?:here(?:'|)?s|here is|sentence|summary|answer)"
r"\b[^:\n]{0,60}:\s*", re.I)
def _pct_int(pct) -> int:
"""A percentile as a whole number for the prompt, clamped to 1..99.
Same floor(x + 0.5) and same clamp as grading.pct_ordinal, deliberately: a
narrative saying "warmer than 92%" sitting beside a UI reading "92nd" is the
least confusing outcome, and the clamp keeps "warmer than 100% of days"
which reads as a measurement error off the page."""
return min(99, max(1, math.floor(float(pct) + 0.5)))
def narrative_key(target: datetime.date) -> str:
"""Derived-store key: one narrative per cell per date."""
return target.isoformat()
def narrative_token(model: str | None = None) -> str:
"""Validity token. Deliberately independent of the climate sync stamps.
The obvious move folding ``recent_synced_at`` in, the way
``payloads.recent_token`` does would invalidate every narrative every time
the forecast bundle refreshes (~4h). Regenerating the whole grid that often
is not affordable against a single desktop GPU, let alone the CPU fallback,
and it buys nothing: a sentence about a day being "cooler than four nights in
five" does not stop being true because the high moved a tenth of a degree.
Freshness is instead the generator's job — it re-runs for the current day on
its own cadence and overwrites in place (put_narrative upserts). This token
exists to orphan rows when the *phrasing contract* changes: the prompt/house
style (NARRATIVE_VER) or the model behind it."""
return f"{NARRATIVE_VER}:{model or MODEL}"
def _day_row(payload: dict, target: datetime.date) -> dict | None:
"""The graded row for ``target`` inside a /grade payload's ``recent`` array."""
want = target.isoformat()
for row in payload.get("recent") or ():
if row.get("date") == want:
return row
return None
def facts_from_grade(payload: dict, target: datetime.date,
place: str | None = None) -> dict | None:
"""Reduce a /grade payload to the handful of facts the model may use.
Returns None when the target day carries no gradeable metric at all there
is nothing to say about it, and an empty fact list would invite the model to
invent something. Note what is *not* in the result: no temperatures, no
cell coordinates, no climatology arrays. See the module docstring.
``place`` overrides the payload's own label. The payload carries a
*reverse-geocoded* name for the ~2-mile cell, which in a dense city is a
neighbourhood grading Shanghai yields "Nanjingdonglu Subdistrict", and a
sentence opening on that reads like it is about somewhere else. A caller that
already knows which city it asked about should say so."""
row = _day_row(payload, target)
if not row:
return None
metrics = []
for key, label, noun in _METRICS:
graded = row.get(key)
if not isinstance(graded, dict):
continue
grade = graded.get("grade")
if not grade:
continue
pct = graded.get("percentile")
# A dry day has a tier ("Dry") but no percentile — it is ranked among rain
# days only, and it isn't one. Carry pct=None and let build_prompt say so
# in words rather than emitting a percentage that doesn't exist.
metrics.append({"label": label, "grade": grade, "noun": noun,
"pct": _pct_int(pct) if pct is not None else None})
if not metrics:
return None
if place is None:
raw = payload.get("place")
place = (raw or {}).get("label") if isinstance(raw, dict) else raw
return {"place": place, "date": target.isoformat(), "metrics": metrics}
def build_prompt(facts: dict) -> str:
"""The full prompt for one narrative. Pure function of ``facts`` — same facts
in, same prompt out, which is what makes a regeneration reproducible."""
when = datetime.date.fromisoformat(facts["date"]).strftime("%-d %B")
lines = [
"You write one-sentence summaries for Thermograph, a service that grades "
"how unusual a day's weather is against about 45 years of that exact "
"location's own history.",
"",
"Rules:",
"- Describe how UNUSUAL the day is for this place at this time of year.",
"- Use only the facts listed below. Do not add any fact that is not there.",
"- Never state a temperature, a rainfall amount, or any other measurement. "
"You have not been given any, and inventing one is a serious error.",
"- Never forecast, predict, or give advice.",
"- One sentence, at most 25 words. Plain prose — no markdown, no quotes, "
"no preamble, no trailing commentary.",
"",
"Facts:",
]
if facts.get("place"):
lines.append(f"Place: {facts['place']}")
lines.append(f"Date: {when}")
for m in facts["metrics"]:
if m["pct"] is None:
# The only percentile-less case in practice is a dry day, whose tier
# ("Dry") is already a plain-English statement of the fact.
lines.append(f"{m['label']}: {m['grade'].lower()} — no measurable rain")
continue
# "warmer/heavier than N% of <noun> at this time of year here" — the
# direction is stated, so the tier cannot be read backwards. See _METRICS.
verb = "heavier" if m["label"] == "Rain" else "warmer"
lines.append(f"{m['label']}: {verb} than {m['pct']}% of {m['noun']} "
f"at this time of year here (tier: {m['grade']})")
lines += ["", "Sentence:"]
return "\n".join(lines)
def _sanitize(text: str) -> str | None:
"""Normalize a raw completion, or None if it isn't fit to store.
Rejecting is always safe here: the caller stores nothing and the endpoint
serves null, which renders as an absent sentence rather than a wrong one."""
if not text:
return None
text = _THINK.sub(" ", text)
text = _LABEL.sub("", _LEADIN.sub("", text))
text = re.sub(r"\s+", " ", text).strip()
text = text.strip('"“”‘’\'').strip()
if len(text) < 20: # too short to be a real sentence
return None
if _BANNED.search(text): # a measurement or a forecast it was never given
return None
if len(text) > MAX_CHARS:
# Truncate at the last sentence end that fits; if there isn't one, the
# model rambled past the limit without punctuation — treat that as a
# failed generation rather than storing a fragment.
cut = max(text.rfind(". ", 0, MAX_CHARS), text.rfind("! ", 0, MAX_CHARS),
text.rfind("? ", 0, MAX_CHARS))
if cut < 20:
return None
text = text[:cut + 1]
return text
def generate(facts: dict, *, model: str | None = None, client: httpx.Client | None = None) -> str | None:
"""One narrative for one graded day, or None on any failure.
Fail-soft by design and by convention (the derived store's readers behave the
same way): an unreachable Ollama, a cold model that blows the timeout, a
refusal, or output that trips the sanitizer all return None. The batch job
logs it, skips the cell, and moves on a missing narrative is a non-event,
a wrong one is not."""
prompt = build_prompt(facts)
body = {
"model": model or MODEL,
"prompt": prompt,
"stream": False,
# Reasoning buys nothing for a one-sentence rephrasing and costs the bulk
# of the tokens — decisive when the GPU is unavailable and this is running
# on CPU at single-digit tokens/sec.
"think": False,
"options": {
# Low but not zero: greedy decoding on a small instruct model tends to
# produce the same stock opener for every cell, which reads worse
# across a grid than mild variation.
"temperature": 0.4,
"top_p": 0.9,
# ~25 words plus slack; a hard stop is the cheapest defence against a
# model that decides to write an essay.
"num_predict": 96,
"stop": ["\n\n"],
},
}
try:
if client is not None:
r = client.post(f"{OLLAMA_URL}/api/generate", json=body, timeout=TIMEOUT)
else:
with httpx.Client(timeout=TIMEOUT) as c:
r = c.post(f"{OLLAMA_URL}/api/generate", json=body)
r.raise_for_status()
return _sanitize((r.json() or {}).get("response") or "")
except Exception: # noqa: BLE001 - see docstring: every failure is a skip
return None
def health() -> dict:
"""Whether the configured Ollama can serve the configured model, and where it
would run. Used by the batch job to fail fast with a useful message instead of
grinding through a thousand cells that will each time out.
``processor`` is the interesting field in practice: Ollama reports "100% CPU"
when no accelerator is present, which is the difference between a full-grid
pass taking minutes and taking most of a day."""
out = {"url": OLLAMA_URL, "model": MODEL, "reachable": False,
"model_present": False, "processor": None}
try:
with httpx.Client(timeout=10.0) as c:
tags = c.get(f"{OLLAMA_URL}/api/tags").json()
out["reachable"] = True
out["model_present"] = any(
m.get("name") == MODEL or m.get("model") == MODEL
for m in tags.get("models") or ()
)
for m in (c.get(f"{OLLAMA_URL}/api/ps").json() or {}).get("models") or ():
if m.get("name") == MODEL or m.get("model") == MODEL:
total = m.get("size") or 0
gpu = m.get("size_vram") or 0
out["processor"] = ("100% CPU" if not gpu
else "100% GPU" if gpu >= total
else f"{round(100 * gpu / total)}% GPU")
except Exception: # noqa: BLE001 - health is advisory; callers handle False
pass
return out

View file

@ -68,15 +68,6 @@ CREATE TABLE IF NOT EXISTS revgeo (
label TEXT, -- NULL == lookup ran but found no label label TEXT, -- NULL == lookup ran but found no label
updated_at REAL NOT NULL updated_at REAL NOT NULL
); );
CREATE TABLE IF NOT EXISTS narrative (
cell_id TEXT NOT NULL,
key TEXT NOT NULL, -- target date (ISO)
token TEXT NOT NULL, -- validity: narrator.narrative_token() (version + model)
text TEXT NOT NULL, -- the sentence; short enough not to be worth compressing
model TEXT NOT NULL, -- which model wrote it, for provenance in the payload
updated_at REAL NOT NULL,
PRIMARY KEY (cell_id, key)
);
""" """
# Postgres mirror of _SCHEMA. UNLOGGED = fast, crash-non-durable (the cache is # Postgres mirror of _SCHEMA. UNLOGGED = fast, crash-non-durable (the cache is
@ -102,24 +93,6 @@ _PG_SCHEMA = (
updated_at DOUBLE PRECISION NOT NULL updated_at DOUBLE PRECISION NOT NULL
) )
""", """,
# LOGGED, unlike its two siblings above — the one table here that is not
# cheaply rebuildable. `derived` and `revgeo` are UNLOGGED because losing them
# costs a recompute from parquet or a Nominatim round-trip. Losing `narrative`
# costs an LLM pass over the whole grid on a single desktop GPU (and far worse
# on the CPU fallback), which is hours, not milliseconds. Durability is worth
# more than the write throughput here: narratives are written once per cell
# per day by a batch job, never on the request path.
"""
CREATE TABLE IF NOT EXISTS narrative (
cell_id TEXT NOT NULL,
key TEXT NOT NULL,
token TEXT NOT NULL,
text TEXT NOT NULL,
model TEXT NOT NULL,
updated_at DOUBLE PRECISION NOT NULL,
PRIMARY KEY (cell_id, key)
)
""",
) )
# Statement text differs by dialect (placeholder style + upsert syntax); the SQLite # Statement text differs by dialect (placeholder style + upsert syntax); the SQLite
@ -140,23 +113,11 @@ if IS_POSTGRES:
"INSERT INTO revgeo (key, label, updated_at) VALUES (%s,%s,%s) " "INSERT INTO revgeo (key, label, updated_at) VALUES (%s,%s,%s) "
"ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, updated_at=EXCLUDED.updated_at" "ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, updated_at=EXCLUDED.updated_at"
) )
_SQL_GET_NARRATIVE = (
"SELECT token, text, model, updated_at FROM narrative WHERE cell_id=%s AND key=%s")
_SQL_PUT_NARRATIVE = (
"INSERT INTO narrative (cell_id, key, token, text, model, updated_at) "
"VALUES (%s,%s,%s,%s,%s,%s) "
"ON CONFLICT (cell_id, key) DO UPDATE SET "
"token=EXCLUDED.token, text=EXCLUDED.text, model=EXCLUDED.model, "
"updated_at=EXCLUDED.updated_at"
)
else: else:
_SQL_GET_PAYLOAD = "SELECT token, payload FROM derived WHERE kind=? AND cell_id=? AND key=?" _SQL_GET_PAYLOAD = "SELECT token, payload FROM derived WHERE kind=? AND cell_id=? AND key=?"
_SQL_PUT_PAYLOAD = "INSERT OR REPLACE INTO derived VALUES (?,?,?,?,?,?)" _SQL_PUT_PAYLOAD = "INSERT OR REPLACE INTO derived VALUES (?,?,?,?,?,?)"
_SQL_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=?" _SQL_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=?"
_SQL_PUT_REVGEO = "INSERT OR REPLACE INTO revgeo VALUES (?,?,?)" _SQL_PUT_REVGEO = "INSERT OR REPLACE INTO revgeo VALUES (?,?,?)"
_SQL_GET_NARRATIVE = (
"SELECT token, text, model, updated_at FROM narrative WHERE cell_id=? AND key=?")
_SQL_PUT_NARRATIVE = "INSERT OR REPLACE INTO narrative VALUES (?,?,?,?,?,?)"
# SQLite connections aren't shareable across threads; uvicorn serves requests on # SQLite connections aren't shareable across threads; uvicorn serves requests on
# a thread pool, so each thread lazily opens (and keeps, for its lifetime) its own # a thread pool, so each thread lazily opens (and keeps, for its lifetime) its own
@ -351,55 +312,15 @@ def put_revgeo(key: str, label: str | None) -> None:
pass pass
# --- grade narratives --------------------------------------------------------
# Written only by scripts/generate_narratives.py, read only by
# api/narrative_routes.py. Same token-as-validity rule as `derived`: a row whose
# stored token doesn't match the caller's is a miss, so a prompt or model change
# can never serve text written under the old contract.
def get_narrative(cell_id: str, key: str, token: str) -> dict | None:
"""The stored sentence for one cell/date, or None when absent or token-invalid.
Returns the provenance alongside the text the endpoint surfaces which model
wrote it and when, so a narrative that reads oddly can be traced to a specific
generation without guessing."""
try:
with _conn() as conn:
if conn is None:
return None
row = conn.execute(_SQL_GET_NARRATIVE, (cell_id, key)).fetchone()
if row is None or row[0] != token:
return None
return {"text": row[1], "model": row[2], "generated_at": row[3]}
except Exception: # noqa: BLE001
return None
def put_narrative(cell_id: str, key: str, token: str, text: str, model: str) -> None:
"""Upsert one narrative. Overwriting in place is the freshness mechanism for
the current day see narrator.narrative_token for why the token deliberately
doesn't move with the climate sync stamps."""
try:
with _conn() as conn:
if conn is None:
return
conn.execute(_SQL_PUT_NARRATIVE, (cell_id, key, token, text, model, time.time()))
conn.commit()
except Exception: # noqa: BLE001
pass
def stats() -> dict: def stats() -> dict:
"""Row counts + on-disk size, for the migrate script's summary output.""" """Row counts + on-disk size, for the migrate script's summary output."""
out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, "bytes": 0}
"narrative": 0, "bytes": 0}
try: try:
with _conn() as conn: with _conn() as conn:
if conn is None: if conn is None:
return out return out
out["derived"] = conn.execute("SELECT COUNT(*) FROM derived").fetchone()[0] out["derived"] = conn.execute("SELECT COUNT(*) FROM derived").fetchone()[0]
out["revgeo"] = conn.execute("SELECT COUNT(*) FROM revgeo").fetchone()[0] out["revgeo"] = conn.execute("SELECT COUNT(*) FROM revgeo").fetchone()[0]
out["narrative"] = conn.execute("SELECT COUNT(*) FROM narrative").fetchone()[0]
# Recent writes may still live in the WAL sidecar — count both files. # Recent writes may still live in the WAL sidecar — count both files.
out["bytes"] = sum(os.path.getsize(p) for p in (DB_PATH, DB_PATH + "-wal") out["bytes"] = sum(os.path.getsize(p) for p in (DB_PATH, DB_PATH + "-wal")
if os.path.exists(p)) if os.path.exists(p))

View file

@ -26,7 +26,7 @@ services:
# It read admin_emi/thermograph-backend/app (the retired split-era path) until this # It read admin_emi/thermograph-backend/app (the retired split-era path) until this
# was corrected; harmless for the default `:local` build, wrong the moment # was corrected; harmless for the default `:local` build, wrong the moment
# BACKEND_IMAGE_TAG names a real published tag. # BACKEND_IMAGE_TAG names a real published tag.
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View file

@ -31,11 +31,10 @@ import threading
import time import time
import polars as pl import polars as pl
from fastapi import Depends, FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from api.internal_routes import _require_internal_token
from data import era5lake from data import era5lake
CACHE_DIR = os.environ.get("THERMOGRAPH_LAKE_CACHE", "/state/lake-cache") CACHE_DIR = os.environ.get("THERMOGRAPH_LAKE_CACHE", "/state/lake-cache")
@ -134,32 +133,6 @@ _FORBIDDEN = re.compile(
r"\b(insert|update|delete|drop|create|alter|attach|copy|truncate|install|load" r"\b(insert|update|delete|drop|create|alter|attach|copy|truncate|install|load"
r"|export|import|pragma|set|call)\b", re.I) r"|export|import|pragma|set|call)\b", re.I)
# The verb denylist above is not a security boundary on its own: DuckDB's file
# readers and setting introspection are plain SELECTs, so every one of these used
# to sail past it. `SELECT current_setting('s3_secret_access_key')` handed the
# bucket credentials straight back to the caller -- the precise outcome the
# module docstring says this service exists to prevent -- and read_csv_auto()/
# glob() turned /query into an arbitrary read of the container filesystem.
#
# A denylist over a full query language is not winnable, so this is defence in
# depth only. The real boundaries are, in order: the shared-secret gate on the
# route, DuckDB SECRETs (opaque to current_setting) instead of SET s3_*, and
# disabled_filesystems in bucket mode. See _connect().
_DENIED_FUNCS = re.compile(
r"\b(read_csv|read_csv_auto|read_parquet|parquet_scan|parquet_metadata"
r"|parquet_schema|read_json|read_json_auto|read_json_objects|read_ndjson"
r"|read_ndjson_auto|read_text|read_blob|glob|iceberg_scan|iceberg_metadata"
r"|delta_scan|sniff_csv|current_setting|getenv|duckdb_settings"
r"|duckdb_secrets|duckdb_extensions|duckdb_functions|sql_auto_complete"
r"|which_secret|open|shell)\s*\(", re.I)
# `FROM 'some/path'` is DuckDB's replacement scan: a bare string literal in table
# position reads that path as a file. Legitimate queries name era5_daily or
# manifest, never a literal.
_FROM_LITERAL = re.compile(r"\b(from|join)\s*\(?\s*['\"]", re.I)
_GUARD_DETAIL = "only reads of the era5_daily and manifest views are allowed"
def _connect(): def _connect():
"""A fresh in-memory DuckDB with the lake views registered. Returns None """A fresh in-memory DuckDB with the lake views registered. Returns None
@ -191,14 +164,11 @@ def _connect():
con.execute("LOAD httpfs") # both baked into the image at build time, con.execute("LOAD httpfs") # both baked into the image at build time,
con.execute("LOAD iceberg") # as the runtime user (see Dockerfile) con.execute("LOAD iceberg") # as the runtime user (see Dockerfile)
host = cfg["endpoint"].split("://", 1)[-1] host = cfg["endpoint"].split("://", 1)[-1]
# A SECRET, not `SET s3_secret_access_key`: settings are readable back out con.execute(f"SET s3_endpoint='{host}'")
# with current_setting(), so the old form let any caller of /query retrieve con.execute("SET s3_url_style='path'")
# the bucket credentials. Secret values are opaque -- duckdb_secrets() lists con.execute(f"SET s3_region='{cfg['region']}'")
# the name and scope but never the key material. con.execute("SET s3_access_key_id=?", [cfg["access_key"]])
con.execute( con.execute("SET s3_secret_access_key=?", [cfg["secret_key"]])
"CREATE OR REPLACE SECRET lake (TYPE S3, KEY_ID ?, SECRET ?, "
"REGION ?, ENDPOINT ?, URL_STYLE 'path')",
[cfg["access_key"], cfg["secret_key"], cfg["region"], host])
# Newest metadata JSON wins (one single-page LIST of metadata/ — cheap); # Newest metadata JSON wins (one single-page LIST of metadata/ — cheap);
# pyiceberg names them NNNNN-uuid.metadata.json, so lexicographic max is # pyiceberg names them NNNNN-uuid.metadata.json, so lexicographic max is
# the numeric max. # the numeric max.
@ -210,26 +180,16 @@ def _connect():
con.execute(f"CREATE VIEW era5_daily AS SELECT * FROM iceberg_scan('{meta}')") con.execute(f"CREATE VIEW era5_daily AS SELECT * FROM iceberg_scan('{meta}')")
con.execute(f"""CREATE VIEW manifest AS SELECT * FROM con.execute(f"""CREATE VIEW manifest AS SELECT * FROM
read_parquet('s3://{cfg['bucket']}/{era5lake.MANIFEST_KEY}')""") read_parquet('s3://{cfg['bucket']}/{era5lake.MANIFEST_KEY}')""")
# Views are resolved lazily, so this must come after they are created --
# and only in bucket mode, where every scan goes over httpfs and nothing
# legitimate touches local disk. It makes read_csv_auto('/etc/passwd') and
# glob('/etc/*') a PermissionException in the engine rather than relying on
# the regex above. lock_configuration then stops a caller re-enabling it
# (belt and braces: `set` is already a forbidden verb).
con.execute("SET disabled_filesystems='LocalFileSystem'")
con.execute("SET lock_configuration=true")
return con return con
@app.post("/query", dependencies=[Depends(_require_internal_token)]) @app.post("/query")
def query(q: Query): def query(q: Query):
sql = q.sql.strip().rstrip(";").strip() sql = q.sql.strip().rstrip(";").strip()
if not sql.lower().startswith(("select", "with")) or ";" in sql \ if not sql.lower().startswith(("select", "with")) or ";" in sql \
or _FORBIDDEN.search(sql): or _FORBIDDEN.search(sql):
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail="only a single SELECT statement is allowed") detail="only a single SELECT statement is allowed")
if _DENIED_FUNCS.search(sql) or _FROM_LITERAL.search(sql):
raise HTTPException(status_code=400, detail=_GUARD_DETAIL)
con = _connect() con = _connect()
if con is None: if con is None:
raise HTTPException(status_code=503, detail="lake not configured") raise HTTPException(status_code=503, detail="lake not configured")

View file

@ -15,11 +15,8 @@ Register the command definitions with scripts/register_discord_commands.py.
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import difflib
import json import json
import os import os
import re
import unicodedata
from nacl.exceptions import BadSignatureError from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey from nacl.signing import VerifyKey
@ -56,109 +53,20 @@ def verify(signature: str, timestamp: str, body: bytes, public_key: str | None =
return False return False
def _fold(s: str) -> str:
"""Casefold and strip accents, so `Zurich` matches `Zürich` and `Sao Paulo`
matches `São Paulo`. People type city names without the diacritics far more
often than with them."""
s = unicodedata.normalize("NFKD", s or "")
return "".join(ch for ch in s if not unicodedata.combining(ch)).casefold().strip()
def _qualifier_matches(city: dict, tail: str) -> bool:
"""Does the bit after the comma in "Vilnius, Lithuania" describe this city?
Accepts the country, its ISO code, or the admin1 region, since all three are
things people write there ("Paris, FR", "Springfield, Illinois")."""
if not tail:
return True
return tail in {
_fold(city.get("country", "")),
_fold(city.get("country_code", "")),
_fold(city.get("admin1", "")),
}
# Below this length, a name is too collision-prone to look for inside a
# sentence — real entries like "Of" or "Ube" would match ordinary English.
_MIN_FREE_TEXT_NAME = 4
def _resolve_city(query: str) -> dict | None: def _resolve_city(query: str) -> dict | None:
"""Best curated-city match for whatever someone actually typed. """Best curated-city match for a free-text name: exact name first, then a
prefix match, preferring the largest (cities.json is population-sorted)."""
The old version matched the query against `name` alone, exact-or-prefix. q = (query or "").strip().casefold()
That failed the two most natural things to write. "Vilnius, Lithuania" never
matched, because the comma form was compared verbatim against a bare name
the country was in the record all along and simply never consulted. And a
single transposed letter ("Vilinus") fell straight through to "I don't track
a city called ", which reads as *we've never heard of Vilnius* rather than
*you typed it wrong*.
So, in order of how confident each step is:
1. exact name, honouring a "City, Country/Region" qualifier;
2. the same, ignoring an unrecognised qualifier better to grade Paris
and say so in the reply than to refuse over "Paris, Wherever";
3. prefix ("san fran");
4. a close-enough name, for typos;
5. a city or country named somewhere inside a sentence, so
"tell me about weather in lithuania" lands on Vilnius.
All of it prefers the largest city, since cities.json is population-sorted
and the biggest is the likeliest thing meant by an ambiguous name.
"""
q = _fold(query)
if not q: if not q:
return None return None
exact, prefix = None, None
# "Vilnius, Lithuania" -> head "vilnius", tail "lithuania". Only the first for c in cities.all_cities(): # already sorted by population, desc
# comma splits: "Washington, DC, USA" keeps "dc, usa" as the qualifier, and name = c["name"].casefold()
# the ISO/admin1 check below still matches it on "dc". if name == q:
head, _, tail = q.partition(",") exact = exact or c
head, tail = head.strip(), tail.strip() elif prefix is None and name.startswith(q):
all_cities = cities.all_cities() # already sorted by population, desc
named = None # name matched, qualifier did not
prefix = None
for c in all_cities:
name = _fold(c["name"])
if name == head:
if _qualifier_matches(c, tail):
return c
named = named or c
elif prefix is None and head and name.startswith(head):
prefix = c prefix = c
if named: return exact or prefix
return named
if prefix:
return prefix
# Typos. 0.82 is tight enough that "vilinus" reaches "vilnius" while
# genuinely different names stay apart; get_close_matches ranks by ratio,
# and ties resolve to the more populous city because we scan in order.
names = [_fold(c["name"]) for c in all_cities]
close = difflib.get_close_matches(head, names, n=1, cutoff=0.82)
if close:
return all_cities[names.index(close[0])]
# A sentence, or a country on its own. Look for the longest city name
# mentioned in it, then fall back to the largest city of a country named in
# it — "weather in lithuania" is a perfectly reasonable thing to ask.
words = set(re.findall(r"[a-z0-9]+", q))
best, best_len = None, 0
for c in all_cities:
name = _fold(c["name"])
if len(name) < _MIN_FREE_TEXT_NAME or len(name) <= best_len:
continue
# Whole-word containment, so "Nice" does not fire on "nicely".
if set(re.findall(r"[a-z0-9]+", name)) <= words:
best, best_len = c, len(name)
if best:
return best
for c in all_cities:
country = _fold(c.get("country", ""))
if len(country) >= _MIN_FREE_TEXT_NAME and set(re.findall(r"[a-z0-9]+", country)) <= words:
return c # population-sorted: the largest
return None
def _grade_message(query: str) -> dict: def _grade_message(query: str) -> dict:
@ -166,14 +74,8 @@ def _grade_message(query: str) -> dict:
plain ephemeral note when the city is unknown or not yet warmed.""" plain ephemeral note when the city is unknown or not yet warmed."""
city = _resolve_city(query) city = _resolve_city(query)
if city is None: if city is None:
# Don't read a whole sentence back as if it were a city name — quoting return {"content": f"I don't track a city called '{query}' yet. "
# "Tell me about weather in lithuania" as a place we don't track is what "Try a major city name.", "flags": _EPHEMERAL}
# makes this look broken rather than merely unmatched.
looks_like_a_name = len(query) <= 40 and len(query.split()) <= 4
detail = f"I don't track a city called '{query}' yet." if looks_like_a_name \
else "I couldn't find a city in that."
return {"content": f"{detail} Try a city name — \"Vilnius\" or "
"\"Vilnius, Lithuania\" both work.", "flags": _EPHEMERAL}
card = homepage._grade_city(city, datetime.date.today()) card = homepage._grade_city(city, datetime.date.today())
if card is None: if card is None:
return {"content": f"No recent reading is cached for {city['name']} yet. " return {"content": f"No recent reading is cached for {city['name']} yet. "

View file

@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""Fill the narrative cache — the batch half of the plain-English day summaries.
This is the only thing in the codebase that calls the LLM. It walks the city
list, asks the running API for each city's graded day, has a local model phrase
it, and writes the sentence to the derived store. ``/api/v2/narrative`` then
serves those rows without ever touching a model.
# on the desktop, where Ollama is
python scripts/generate_narratives.py --limit 25
# from a host that reaches the desktop across the mesh
THERMOGRAPH_OLLAMA_URL=http://10.x.x.x:11434 \
python scripts/generate_narratives.py
Two connections, deliberately separate:
* ``--api-base`` (or ``THERMOGRAPH_API_BASE_INTERNAL``) where to read graded
days from. Reusing the real endpoint means this script inherits the whole
fetch/grade/cache pipeline instead of reimplementing it, and warms the grade
cache as a side effect.
* ``THERMOGRAPH_OLLAMA_URL`` where inference runs. It needs no access to
anything else, and nothing waits on it but this process.
Safe to interrupt and safe to re-run: work is per-city and committed as it goes,
and an existing valid row is skipped unless ``--force``. Nothing here writes to
climate data the only table it touches is ``narrative``.
"""
import argparse
import datetime
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import httpx # noqa: E402
from data import cities # noqa: E402
from data import grid # noqa: E402
from data import narrator # noqa: E402
from data import store # noqa: E402
DEFAULT_API = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL", "http://127.0.0.1:8000").rstrip("/")
# The app mounts its API under THERMOGRAPH_BASE (default "/thermograph"), but a
# deployment can and does run with it empty — the LAN dev container serves
# /api/v2/... flat. Guessing wrong turns every city into a 404, so the prefix is
# probed once against the I/O-free /api/version rather than assumed.
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
_PREFIXES = tuple(dict.fromkeys([f"/{_BASE}" if _BASE else "", ""]))
def _detect_prefix(client: httpx.Client, api: str) -> str | None:
for prefix in _PREFIXES:
try:
if client.get(f"{api}{prefix}/api/version", timeout=15).status_code == 200:
return prefix
except Exception: # noqa: BLE001 - try the next candidate
continue
return None
def _grade(client: httpx.Client, api: str, prefix: str, lat: float, lon: float,
target: datetime.date) -> dict | None:
"""One city's /grade payload, or None if the API couldn't produce it.
``after=0`` and a short history window keep the response small: the narrative
only ever describes the target day, so grading two weeks either side of it
would be work this script pays for and then discards."""
try:
r = client.get(f"{api}{prefix}/api/v2/grade",
params={"lat": lat, "lon": lon, "date": target.isoformat(),
"days": 1, "after": 0})
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
print(f" grade failed: {type(exc).__name__}", flush=True)
return None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--api-base", default=DEFAULT_API,
help=f"backend base URL to read graded days from (default {DEFAULT_API})")
ap.add_argument("--date", default=None, help="target date YYYY-MM-DD (default today)")
ap.add_argument("--limit", type=int, default=0, help="stop after N cities (0 = all)")
ap.add_argument("--slug", action="append", default=[],
help="generate only this city slug (repeatable)")
ap.add_argument("--force", action="store_true",
help="regenerate even when a valid row already exists")
ap.add_argument("--dry-run", action="store_true",
help="print the sentence instead of storing it")
args = ap.parse_args()
target = (datetime.date.fromisoformat(args.date) if args.date
else datetime.date.today())
token = narrator.narrative_token()
key = narrator.narrative_key(target)
# Fail fast and loudly rather than timing out a thousand times in a row. The
# processor line is the one people actually need: an accelerator that has
# silently dropped out turns a minutes-long pass into an all-day one.
hp = narrator.health()
print(f"ollama : {hp['url']} reachable={hp['reachable']} "
f"model={hp['model']} present={hp['model_present']}")
if not hp["reachable"]:
print("ERROR: cannot reach Ollama. Set THERMOGRAPH_OLLAMA_URL.", file=sys.stderr)
return 1
if not hp["model_present"]:
print(f"ERROR: model {hp['model']!r} not pulled. Run: ollama pull {hp['model']}",
file=sys.stderr)
return 1
work = cities.all_cities()
if args.slug:
wanted = set(args.slug)
work = [c for c in work if c["slug"] in wanted]
missing = wanted - {c["slug"] for c in work}
if missing:
print(f"ERROR: unknown slug(s): {', '.join(sorted(missing))}", file=sys.stderr)
return 1
if args.limit > 0:
work = work[:args.limit]
print(f"target : {target} token={token}")
print(f"cities : {len(work)}\n")
made = skipped = failed = 0
started = time.time()
with httpx.Client(timeout=120.0) as api_client, httpx.Client(timeout=narrator.TIMEOUT) as llm:
prefix = _detect_prefix(api_client, args.api_base)
if prefix is None:
print(f"ERROR: no Thermograph API at {args.api_base} "
f"(tried {', '.join(repr(p) for p in _PREFIXES)})", file=sys.stderr)
return 1
print(f"api : {args.api_base}{prefix}/api/v2\n")
for n, city in enumerate(work, 1):
label = f"{city['name']}, {city['country']}"
cell = grid.snap(city["lat"], city["lon"])
if not args.force and store.get_narrative(cell["id"], key, token):
skipped += 1
continue
print(f"[{n}/{len(work)}] {label}", flush=True)
payload = _grade(api_client, args.api_base, prefix,
city["lat"], city["lon"], target)
if payload is None:
failed += 1
continue
# Name the city we asked about, not the cell's reverse-geocoded
# neighbourhood — see narrator.facts_from_grade.
facts = narrator.facts_from_grade(payload, target,
place=cities.display_name(city))
if facts is None:
# No gradeable metric for this day — usually a cell whose recent
# bundle hasn't synced yet. Not an error; it'll be picked up on a
# later pass once the data lands.
print(" no graded data for the target day — skipping", flush=True)
skipped += 1
continue
text = narrator.generate(facts, client=llm)
if not text:
print(" generation failed or was rejected by the sanitizer", flush=True)
failed += 1
continue
print(f" {text}", flush=True)
if not args.dry_run:
store.put_narrative(cell["id"], key, token, text, narrator.MODEL)
made += 1
elapsed = time.time() - started
print(f"\ndone in {elapsed:.0f}s — {made} written, {skipped} skipped, {failed} failed")
if args.dry_run:
print("(dry run — nothing was stored)")
# A run that produced nothing while having work to do is a failure worth a
# non-zero exit, so a cron wrapper notices.
return 1 if made == 0 and failed > 0 else 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -14,7 +14,7 @@ export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-smoke}"
# The image to smoke, pulled from the registry (no local build). CI passes the just- # The image to smoke, pulled from the registry (no local build). CI passes the just-
# pushed sha (BACKEND_IMAGE_TAG=sha-<12hex>); locally it defaults to a published tag. # pushed sha (BACKEND_IMAGE_TAG=sha-<12hex>); locally it defaults to a published tag.
export BACKEND_IMAGE_TAG="${BACKEND_IMAGE_TAG:-v0.0.2-split-ci}" export BACKEND_IMAGE_TAG="${BACKEND_IMAGE_TAG:-v0.0.2-split-ci}"
IMG="${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-admin_emi/thermograph-backend/app}:${BACKEND_IMAGE_TAG}" IMG="${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-admin_emi/thermograph-backend/app}:${BACKEND_IMAGE_TAG}"
export SMOKE_HOST_PORT="${SMOKE_HOST_PORT:-18137}" export SMOKE_HOST_PORT="${SMOKE_HOST_PORT:-18137}"
COMPOSE=(docker compose -f docker-compose.test.yml) COMPOSE=(docker compose -f docker-compose.test.yml)
PORT_URL="http://127.0.0.1:${SMOKE_HOST_PORT}" PORT_URL="http://127.0.0.1:${SMOKE_HOST_PORT}"

View file

@ -1,109 +0,0 @@
"""/api/v2/narrative — the read-only half of the plain-English day summaries.
The load-bearing assertion here is the negative one: this route must never reach
for a model. ``narrator.generate`` is replaced with a bomb for every test, so any
future edit that makes the request path call inference fails loudly instead of
quietly coupling a public endpoint to a desktop GPU.
"""
import datetime
import pytest
from fastapi.testclient import TestClient
from data import grid
from data import narrator
from data import store
from web import app as appmod
Q = {"lat": 47.6062, "lon": -122.3321}
TODAY = datetime.date.today()
@pytest.fixture
def client(monkeypatch):
def _boom(*a, **kw): # pragma: no cover - failing here is the point
raise AssertionError("the request path must never call the LLM")
monkeypatch.setattr(narrator, "generate", _boom)
return TestClient(appmod.app)
@pytest.fixture
def seeded():
"""Store one narrative for today's cell and hand back its identity."""
cell = grid.snap(Q["lat"], Q["lon"])
key = narrator.narrative_key(TODAY)
token = narrator.narrative_token()
store.put_narrative(cell["id"], key, token,
"A cool night for late July here.", "qwen3:14b")
yield {"cell": cell, "key": key, "token": token}
def test_missing_narrative_is_pending_not_an_error(client):
r = client.get("/thermograph/api/v2/narrative",
params={"lat": -33.8688, "lon": 151.2093})
assert r.status_code == 200
body = r.json()
assert body["narrative"] is None
assert body["status"] == "pending"
assert body["model"] is None
def test_stored_narrative_is_served_with_provenance(client, seeded):
r = client.get("/thermograph/api/v2/narrative", params=Q)
assert r.status_code == 200
body = r.json()
assert body["narrative"] == "A cool night for late July here."
assert body["status"] == "ready"
assert body["model"] == "qwen3:14b"
assert body["generated_at"] > 0
assert body["date"] == TODAY.isoformat()
assert body["cell"]["id"] == seeded["cell"]["id"]
def test_conditional_revalidation_returns_304(client, seeded):
first = client.get("/thermograph/api/v2/narrative", params=Q)
etag = first.headers["ETag"]
again = client.get("/thermograph/api/v2/narrative", params=Q,
headers={"If-None-Match": etag})
assert again.status_code == 304
assert again.content == b""
def test_the_pending_etag_changes_once_the_row_lands(client, seeded):
"""A client that cached a pending response has to notice the sentence
appearing so the two states must not share an ETag."""
ready = client.get("/thermograph/api/v2/narrative", params=Q).headers["ETag"]
pending = client.get("/thermograph/api/v2/narrative",
params={"lat": -33.8688, "lon": 151.2093}).headers["ETag"]
assert ready != pending
def test_a_row_written_under_another_token_is_not_served(client):
"""A prompt or model change must orphan old text rather than serve it."""
cell = grid.snap(35.6762, 139.6503)
store.put_narrative(cell["id"], narrator.narrative_key(TODAY),
"n0:some-old-model", "Written under the old contract.",
"some-old-model")
r = client.get("/thermograph/api/v2/narrative",
params={"lat": 35.6762, "lon": 139.6503})
assert r.status_code == 200
assert r.json()["narrative"] is None
assert r.json()["status"] == "pending"
def test_an_explicit_past_date_is_its_own_row(client, seeded):
r = client.get("/thermograph/api/v2/narrative",
params={**Q, "date": "2020-01-01"})
assert r.status_code == 200
assert r.json()["date"] == "2020-01-01"
assert r.json()["narrative"] is None
def test_an_unparseable_date_is_rejected(client):
r = client.get("/thermograph/api/v2/narrative", params={**Q, "date": "last-tuesday"})
assert r.status_code == 400
@pytest.mark.parametrize("bad", [{"lat": 91, "lon": 0}, {"lat": 0, "lon": 181}])
def test_out_of_range_coordinates_are_rejected(client, bad):
assert client.get("/thermograph/api/v2/narrative", params=bad).status_code == 422

View file

@ -53,69 +53,6 @@ audit.ACTIVITY_DIR = os.path.join(_TMP, "logs", "activity")
audit.HEARTBEAT_DIR = os.path.join(_TMP, "logs", "heartbeat") audit.HEARTBEAT_DIR = os.path.join(_TMP, "logs", "heartbeat")
# --- outbound transports: hard-blocked, not merely unconfigured ---------------
#
# Every send gate in notifications/ is read from ambient env AT IMPORT TIME, and
# every send site swallows its exceptions (notify.py's _flush_sends,
# discord.py's _bot_post). So before this block existed, an operator who had
# sourced /etc/thermograph.env in the shell — routine when debugging — turned
# `pytest tests/notifications` into a live broadcast to real subscribers, and it
# reported GREEN because the exceptions never surfaced. test_notify.py alone
# calls run_pass() seven times without patching notifications.discord.
#
# CI was safe only by omission: it runs the suite in a container with no
# credentials baked in. One added `-e` removed that.
#
# Two layers, deliberately:
# 1. Blank the config so every enabled()/dm_enabled()/subscription_enabled()
# gate reports False — the same state CI runs in, now guaranteed locally.
# 2. Replace the actual transports with sentinels that RAISE. A test that
# forgets to patch one now fails loudly instead of sending silently.
#
# Tests that need to exercise a send path patch these themselves, which is the
# point: the escape hatch is explicit and visible in the test that takes it.
# The idiom is borrowed from tests/test_warm_content.py.
import smtplib # noqa: E402
import indexnow # noqa: E402
from notifications import discord, mailer, push # noqa: E402
class OutboundBlocked(AssertionError):
"""Raised when a test reaches a real outbound transport.
Seeing this is not a flaky test it means the code under test tried to
contact Discord, an SMTP host, a push endpoint or IndexNow for real. Patch
the transport in the test, or assert the no-send path.
"""
def _blocked(what):
def _raise(*a, **kw):
raise OutboundBlocked(
f"test tried to reach {what} for real. Patch it in the test "
f"(see tests/conftest.py's outbound-transport block).")
return _raise
# 1. Config blanked: every gate reports False.
discord.BOT_TOKEN = ""
discord.WEBHOOK_URL = ""
discord.SUBSCRIPTION_CHANNEL_ID = ""
discord.WEATHER_CHANNEL_ID = ""
mailer.BACKEND = "console"
# 2. Transports replaced with raisers.
discord._client.post = _blocked("Discord")
discord._client.patch = _blocked("Discord")
discord._client.put = _blocked("Discord")
discord._client.delete = _blocked("Discord")
indexnow._client.post = _blocked("IndexNow")
push.webpush = _blocked("a Web Push endpoint")
smtplib.SMTP = _blocked("an SMTP host")
smtplib.SMTP_SSL = _blocked("an SMTP host")
def _years_before(d: datetime.date, years: int) -> datetime.date: def _years_before(d: datetime.date, years: int) -> datetime.date:
"""`d` shifted back `years` years, same month/day (Feb 29 → Feb 28).""" """`d` shifted back `years` years, same month/day (Feb 29 → Feb 28)."""
try: try:

View file

@ -1,269 +0,0 @@
"""Narrative generation: fact extraction, prompt building, and output sanitizing.
Hermetic no Ollama is contacted. ``generate`` is exercised through an injected
stub client, and every other function here is pure.
"""
import datetime
import pytest
from data import narrator
TARGET = datetime.date(2026, 7, 25)
def _payload(**over):
"""A /grade payload trimmed to what facts_from_grade reads."""
day = {
"date": "2026-07-25",
"tmax": {"value": 72.2, "percentile": 47.8, "grade": "Normal", "class": "normal"},
"tmin": {"value": 54.3, "percentile": 20.2, "grade": "Low", "class": "cold"},
"feels": {"value": 53.1, "percentile": 27.9, "grade": "Below Normal", "class": "cool"},
"precip": {"value": 0.0, "percentile": None, "grade": "Dry", "class": "dry"},
}
day.update(over.pop("day", {}))
base = {
"place": "Ringaudai, Lithuania",
"recent": [
{"date": "2026-07-24", "tmax": {"value": 60.9, "percentile": 4.0,
"grade": "Very Low", "class": "very-cold"}},
day,
],
}
base.update(over)
return base
class _Resp:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class _Client:
"""Stand-in for httpx.Client that records the request and replays a response."""
def __init__(self, response=None, raises=None):
self.response = response
self.raises = raises
self.calls = []
def post(self, url, json=None, timeout=None):
self.calls.append({"url": url, "json": json})
if self.raises is not None:
raise self.raises
return _Resp(self.response)
# --- fact extraction ---------------------------------------------------------
def test_facts_pick_the_target_day_not_a_neighbour():
facts = narrator.facts_from_grade(_payload(), TARGET)
assert facts["date"] == "2026-07-25"
assert facts["place"] == "Ringaudai, Lithuania"
grades = {m["label"]: m["grade"] for m in facts["metrics"]}
# "Very Low" belongs to the 24th; picking it up would mean the wrong row.
assert grades == {"Daytime high": "Normal", "Overnight low": "Low",
"Feels-like": "Below Normal", "Rain": "Dry"}
def test_facts_never_carry_a_raw_measurement():
"""The safety property the whole design rests on: a model that is never given
a temperature cannot misreport one."""
facts = narrator.facts_from_grade(_payload(), TARGET)
blob = repr(facts) + narrator.build_prompt(facts)
for reading in ("72.2", "54.3", "53.1", "60.9"):
assert reading not in blob
def test_facts_omit_metrics_the_day_does_not_have():
facts = narrator.facts_from_grade(
_payload(day={"tmin": None, "feels": None, "precip": None}), TARGET)
assert [m["label"] for m in facts["metrics"]] == ["Daytime high"]
def test_facts_are_none_when_nothing_is_gradeable():
empty = _payload(day={"tmax": None, "tmin": None, "feels": None, "precip": None})
assert narrator.facts_from_grade(empty, TARGET) is None
def test_facts_are_none_when_the_day_is_absent():
assert narrator.facts_from_grade(_payload(), datetime.date(2026, 8, 9)) is None
def test_facts_accept_a_dict_place_label():
facts = narrator.facts_from_grade(_payload(place={"label": "Kaunas, Lithuania"}), TARGET)
assert facts["place"] == "Kaunas, Lithuania"
def test_an_explicit_place_overrides_the_reverse_geocoded_one():
"""A cell in a dense city reverse-geocodes to a neighbourhood; a caller that
knows it asked about Shanghai should not get a sentence about a subdistrict."""
facts = narrator.facts_from_grade(
_payload(place="Nanjingdonglu Subdistrict"), TARGET, place="Shanghai, China")
assert facts["place"] == "Shanghai, China"
assert "Nanjingdonglu" not in narrator.build_prompt(facts)
def test_dry_day_states_the_tier_without_a_percentage():
"""Rain is ranked among rain days only, so a dry day has a tier and no
percentile it must not render as a percentage that doesn't exist."""
facts = narrator.facts_from_grade(_payload(), TARGET)
rain = next(m for m in facts["metrics"] if m["label"] == "Rain")
assert rain["pct"] is None
prompt = narrator.build_prompt(facts)
assert "Rain: dry — no measurable rain" in prompt
assert "None" not in prompt
assert "%" not in prompt.split("Rain:")[1]
# --- prompt ------------------------------------------------------------------
def test_prompt_is_deterministic_and_carries_every_fact():
facts = narrator.facts_from_grade(_payload(), TARGET)
a, b = narrator.build_prompt(facts), narrator.build_prompt(facts)
assert a == b
assert "Place: Ringaudai, Lithuania" in a
assert "Date: 25 July" in a
assert ("Daytime high: warmer than 48% of days at this time of year here "
"(tier: Normal)") in a
assert ("Overnight low: warmer than 20% of nights at this time of year here "
"(tier: Low)") in a
def test_prompt_states_the_direction_so_a_tier_cannot_be_read_backwards():
"""The regression this guards: "Overnight low: 82nd percentile (High)" was
phrased by the model as a *cool* night an inversion no output filter can
detect. The percentage must carry the direction itself."""
warm_night = _payload(day={"tmin": {"value": 78.0, "percentile": 82.4,
"grade": "High", "class": "hot"}})
prompt = narrator.build_prompt(narrator.facts_from_grade(warm_night, TARGET))
assert "Overnight low: warmer than 82% of nights" in prompt
def test_rain_is_described_as_heavier_not_warmer():
wet = _payload(day={"precip": {"value": 12.0, "percentile": 73.0,
"grade": "Heavy", "class": "wet-6"}})
prompt = narrator.build_prompt(narrator.facts_from_grade(wet, TARGET))
assert "Rain: heavier than 73% of rainy days" in prompt
@pytest.mark.parametrize("pct, shown", [(47.8, 48), (20.2, 20), (0.1, 1), (99.9, 99)])
def test_prompt_percentages_match_the_ui_rounding_and_clamp(pct, shown):
"""Mirrors grading.pct_ordinal — a narrative disagreeing with the number
printed next to it is the kind of thing users report as a bug."""
day = _payload(day={"tmax": {"value": 1.0, "percentile": pct,
"grade": "Normal", "class": "normal"}})
prompt = narrator.build_prompt(narrator.facts_from_grade(day, TARGET))
assert f"Daytime high: warmer than {shown}% of days" in prompt
def test_prompt_omits_the_place_line_when_unknown():
facts = narrator.facts_from_grade(_payload(place=None), TARGET)
assert "Place:" not in narrator.build_prompt(facts)
# --- sanitizing --------------------------------------------------------------
@pytest.mark.parametrize("raw, expected", [
(' "A cool night for late July here." ',
"A cool night for late July here."),
("Sure! Here's a sentence: A cool night for late July in this corner of Lithuania.",
"A cool night for late July in this corner of Lithuania."),
("<think>weighing the tiers</think> A cool night for late July here.",
"A cool night for late July here."),
("A cool night for\nlate July here.",
"A cool night for late July here."),
("Okay, here is the summary: A cool night for late July here.",
"A cool night for late July here."),
])
def test_sanitize_normalizes_common_model_habits(raw, expected):
assert narrator._sanitize(raw) == expected
def test_sanitize_keeps_a_colon_that_belongs_to_the_sentence():
"""Preamble stripping is anchored to known openers on purpose — "everything
before the first colon" would eat half of a perfectly good narrative."""
raw = "One thing stands out: the night was colder than four in five late-July nights."
assert narrator._sanitize(raw) == raw
@pytest.mark.parametrize("raw", [
"",
" ",
"Sure!", # too short to be a sentence
"The high reached 31°C, well above normal for July.", # a reading it was never given
"Rainfall hit 12 mm, far more than usual for the season.", # ditto
"Tomorrow will be even more unusual for this location.", # forecast
"Expect an unusually cold night for late July in this area.", # forecast
"A cold night by local standards, so wear an extra layer tonight.", # advice
])
def test_sanitize_rejects_output_it_should_not_store(raw):
assert narrator._sanitize(raw) is None
def test_sanitize_truncates_a_long_answer_at_a_sentence_boundary():
first = "A distinctly cool night for late July in this part of Lithuania. "
text = narrator._sanitize(first + "B" * 400)
assert text == first.strip()
assert len(text) <= narrator.MAX_CHARS
def test_sanitize_rejects_a_long_answer_with_nowhere_to_cut():
assert narrator._sanitize("A" * 400) is None
# --- generate ----------------------------------------------------------------
def test_generate_returns_the_sanitized_sentence():
facts = narrator.facts_from_grade(_payload(), TARGET)
client = _Client(response={"response": ' "A cool night for late July here." '})
assert narrator.generate(facts, client=client) == "A cool night for late July here."
def test_generate_sends_a_bounded_non_thinking_request():
"""Reasoning tokens dominate cost for a one-sentence rewrite, and an unbounded
generation on a CPU fallback is how a batch job stops finishing."""
facts = narrator.facts_from_grade(_payload(), TARGET)
client = _Client(response={"response": "A cool night for late July here."})
narrator.generate(facts, client=client, model="test-model")
sent = client.calls[0]["json"]
assert sent["model"] == "test-model"
assert sent["stream"] is False
assert sent["think"] is False
assert sent["options"]["num_predict"] <= 128
assert client.calls[0]["url"].endswith("/api/generate")
def test_generate_is_none_when_the_model_is_unreachable():
facts = narrator.facts_from_grade(_payload(), TARGET)
client = _Client(raises=OSError("connection refused"))
assert narrator.generate(facts, client=client) is None
def test_generate_is_none_when_output_fails_the_sanitizer():
facts = narrator.facts_from_grade(_payload(), TARGET)
client = _Client(response={"response": "The high hit 31°C today."})
assert narrator.generate(facts, client=client) is None
def test_generate_is_none_on_an_empty_response():
facts = narrator.facts_from_grade(_payload(), TARGET)
assert narrator.generate(facts, client=_Client(response={})) is None
# --- token -------------------------------------------------------------------
def test_token_tracks_the_version_and_the_model():
base = narrator.narrative_token("qwen3:14b")
assert base != narrator.narrative_token("llama3:8b")
assert base.startswith(narrator.NARRATIVE_VER)
def test_key_is_the_iso_date():
assert narrator.narrative_key(TARGET) == "2026-07-25"

View file

@ -100,65 +100,6 @@ def test_resolve_city_prefers_exact_then_population():
assert di._resolve_city("Zzznotacity") is None assert di._resolve_city("Zzznotacity") is None
# --- what people actually type -----------------------------------------------
#
# Every case below came back "I don't track a city called …" in #general.
# Vilnius was in cities.json the whole time; the resolver never looked past
# `name`.
def test_resolve_city_accepts_city_comma_country():
# The reported bug: the country sat in the record and was never consulted,
# so the most natural way to disambiguate a city always failed.
for query in ("Vilnius, Lithuania", "Vilnius, LT", "vilnius,lithuania"):
city = di._resolve_city(query)
assert city is not None and city["name"] == "Vilnius", query
def test_resolve_city_tolerates_a_typo():
# "Vilinus" is one transposition from Vilnius. Refusing it reads as "we've
# never heard of Vilnius", which is both wrong and off-putting.
for query in ("Vilinus", "Vilinus, Lithuania"):
city = di._resolve_city(query)
assert city is not None and city["name"] == "Vilnius", query
def test_resolve_city_finds_a_place_inside_a_sentence():
# Mentioning the bot is conversational by nature, so the whole message
# arrives as the "city name".
city = di._resolve_city("Tell me about weather in lithuania")
assert city is not None and city["country"] == "Lithuania"
city = di._resolve_city("what's it looking like in Tokyo today")
assert city is not None and city["name"] == "Tokyo"
def test_resolve_city_ignores_diacritics_either_way():
for query in ("Zurich", "Sao Paulo"):
assert di._resolve_city(query) is not None, query
def test_resolve_city_falls_back_when_the_qualifier_is_unknown():
# Better to grade Paris and name it in the reply than refuse over a
# qualifier we don't carry.
city = di._resolve_city("Paris, Wherever")
assert city is not None and city["name"] == "Paris"
def test_resolve_city_does_not_match_ordinary_chatter():
# The free-text pass must not turn small talk into a weather report.
for query in ("how are you today", "what is the weather", "thanks!"):
assert di._resolve_city(query) is None, query
def test_grade_does_not_quote_a_whole_sentence_back():
# Reading a full sentence back as a city we don't track is what made this
# look broken rather than merely unmatched.
sentence = "please tell me something about the climate of Atlantis the lost place"
data = di._grade_message(sentence)
assert data["flags"] == di._EPHEMERAL
assert sentence not in data["content"]
assert "couldn't find a city" in data["content"]
# --- the wired FastAPI route ------------------------------------------------- # --- the wired FastAPI route -------------------------------------------------
def test_interactions_route_pings_and_rejects(monkeypatch): def test_interactions_route_pings_and_rejects(monkeypatch):

View file

@ -1,108 +0,0 @@
"""The guarantee that a test run cannot reach a real subscriber.
tests/conftest.py hard-blocks every outbound transport. That block is worth
nothing unless something asserts it, because it fails in exactly one direction:
if a future edit weakens it, every other test still passes and the only symptom
is a real Discord message, a real email, or a real push to somebody's phone —
delivered silently, because notify.py and discord.py swallow send exceptions.
So this file tests the test harness. If anything here fails, do not weaken the
assertion; fix the block in conftest.py.
"""
import smtplib
import pytest
import indexnow
from notifications import discord, mailer, push
from tests.conftest import OutboundBlocked
# --- layer 1: every send gate reports "not configured" ------------------------
def test_no_discord_surface_is_enabled():
"""A stray THERMOGRAPH_DISCORD_* in the operator's shell must not arm the
bot. These four gates are what stand between run_pass() and a real post to
the subscription channel, which notifies real people."""
assert discord.enabled() is False
assert discord.dm_enabled() is False
assert discord.subscription_enabled() is False
assert discord.weather_enabled() is False
def test_no_discord_credentials_are_live():
assert discord.BOT_TOKEN == ""
assert discord.WEBHOOK_URL == ""
assert discord.SUBSCRIPTION_CHANNEL_ID == ""
assert discord.WEATHER_CHANNEL_ID == ""
def test_mail_never_goes_over_smtp():
"""`console` prints; `smtp` hands the message to a real host. digest.py
branches on exactly this value."""
assert mailer.BACKEND == "console"
assert mailer.enabled() is False
# --- layer 2: the transports themselves raise --------------------------------
#
# Layer 1 is config, and config can be re-enabled by a test that monkeypatches a
# token to exercise a branch. These are the backstop for that case.
def test_discord_transport_raises_rather_than_posting():
with pytest.raises(OutboundBlocked):
discord._client.post("https://discord.com/api/v10/channels/1/messages",
json={"content": "should never leave the box"})
def test_indexnow_transport_raises_rather_than_posting():
with pytest.raises(OutboundBlocked):
indexnow._client.post("https://api.indexnow.org/indexnow", json={})
def test_push_transport_raises_rather_than_sending():
with pytest.raises(OutboundBlocked):
push.webpush(subscription_info={}, data="{}")
def test_smtp_raises_rather_than_connecting():
with pytest.raises(OutboundBlocked):
smtplib.SMTP("127.0.0.1", 25)
with pytest.raises(OutboundBlocked):
smtplib.SMTP_SSL("127.0.0.1", 465)
# --- the paths that used to be able to send silently -------------------------
def test_bot_post_cannot_reach_discord_even_with_a_token(monkeypatch):
"""_bot_post is the funnel for DMs and channel posts. Re-arm the token the
way a careless test might and confirm the transport still refuses.
Note _bot_post swallows exceptions by design, so it returns None rather than
raising the assertion that matters is that the sentinel ran at all, i.e.
that no HTTP request was attempted against the real API.
"""
monkeypatch.setattr(discord, "BOT_TOKEN", "not-a-real-token")
attempted = []
monkeypatch.setattr(discord._client, "post",
lambda *a, **kw: attempted.append(a) or (_ for _ in ()).throw(
OutboundBlocked("blocked")))
assert discord._bot_post("/channels/1/messages", {"content": "x"}) is None
assert attempted, "_bot_post should have gone through _client.post"
def test_run_pass_sends_nothing_when_nothing_is_configured():
"""The regression this whole block exists for: notify.run_pass() reaches
discord.post_subscription_alert() for every notification it creates, and
test_notify.py calls it seven times without patching discord. With the gates
closed it must complete without touching a transport."""
from accounts import db
from notifications import notify
# The app lifespan (which normally creates these) does not run under
# TestClient-less tests, so build the schema the way test_notify.py does.
db.Base.metadata.create_all(db.sync_engine)
# No subscriptions in the throwaway DB => no candidates, no sends. The point
# is that this completes without raising OutboundBlocked.
notify.run_pass()

View file

@ -7,20 +7,12 @@ import polars as pl
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# /query is gated on the same shared secret as /internal/* (it is an operator
# surface, not a public one). The fixture provisions it so the existing query
# tests exercise the authorised path; the auth tests below drive the closed ones.
TOKEN = "test-internal-token"
HDR = {"X-Thermograph-Internal-Token": TOKEN}
@pytest.fixture @pytest.fixture
def lake(tmp_path, monkeypatch): def lake(tmp_path, monkeypatch):
"""A tiny two-point lake (points, hive partitions, manifest) on disk.""" """A tiny two-point lake (points, hive partitions, manifest) on disk."""
from data import era5lake from data import era5lake
monkeypatch.setenv("THERMOGRAPH_INTERNAL_TOKEN", TOKEN)
points = [(154, 1439), (154, 0)] # around London points = [(154, 1439), (154, 0)] # around London
d0 = datetime.date(1940, 1, 1) d0 = datetime.date(1940, 1, 1)
dates = [d0 + datetime.timedelta(days=k) for k in range(12000)] dates = [d0 + datetime.timedelta(days=k) for k in range(12000)]
@ -97,7 +89,7 @@ def test_history_404_for_point_outside_lake(lake):
def test_query_selects_with_partition_predicates(lake): def test_query_selects_with_partition_predicates(lake):
r = lake.post("/query", headers=HDR, json={"sql": r = lake.post("/query", json={"sql":
"SELECT year, month, count(*) AS n FROM era5_daily " "SELECT year, month, count(*) AS n FROM era5_daily "
"WHERE year = 1940 AND month = 1 GROUP BY year, month"}) "WHERE year = 1940 AND month = 1 GROUP BY year, month"})
assert r.status_code == 200 assert r.status_code == 200
@ -107,7 +99,7 @@ def test_query_selects_with_partition_predicates(lake):
def test_query_manifest_view(lake): def test_query_manifest_view(lake):
r = lake.post("/query", headers=HDR, json={"sql": "SELECT count(*) FROM manifest"}) r = lake.post("/query", json={"sql": "SELECT count(*) FROM manifest"})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["rows"][0][0] == 2 assert r.json()["rows"][0][0] == 2
@ -119,115 +111,4 @@ def test_query_manifest_view(lake):
"COPY era5_daily TO 'x'", "COPY era5_daily TO 'x'",
]) ])
def test_query_rejects_non_select(lake, sql): def test_query_rejects_non_select(lake, sql):
assert lake.post("/query", headers=HDR, assert lake.post("/query", json={"sql": sql}).status_code == 400
json={"sql": sql}).status_code == 400
# --- /query is an operator surface, not a public one -------------------------
def test_query_requires_the_internal_token(lake):
"""No header at all: the caller is not the operator. 401, not a result."""
r = lake.post("/query", json={"sql": "SELECT count(*) FROM manifest"})
assert r.status_code == 401
def test_query_rejects_a_wrong_internal_token(lake):
r = lake.post("/query", headers={"X-Thermograph-Internal-Token": "nope"},
json={"sql": "SELECT count(*) FROM manifest"})
assert r.status_code == 401
def test_query_is_closed_when_no_token_is_provisioned(lake, monkeypatch):
"""Unprovisioned means the surface does not exist (404) rather than falling
open to no-auth same posture as /internal/* and /api/v2/metrics."""
monkeypatch.delenv("THERMOGRAPH_INTERNAL_TOKEN", raising=False)
monkeypatch.delenv("THERMOGRAPH_AUTH_SECRET", raising=False)
r = lake.post("/query", headers=HDR,
json={"sql": "SELECT count(*) FROM manifest"})
assert r.status_code == 404
# --- the SELECT guard must reject reads outside the two lake views -----------
#
# The old guard was a denylist of DDL/DML *verbs*, so it passed every one of
# these: DuckDB's reader functions and setting introspection are plain SELECTs.
# In bucket mode `SET s3_secret_access_key` made the bucket credentials readable
# straight back out via current_setting() -- the exact thing this service exists
# to avoid (see the module docstring). These are the attack shapes, not the
# shapes the old regex happened to be written for.
@pytest.mark.parametrize("sql", [
"SELECT * FROM read_csv_auto('/etc/passwd')",
"SELECT * FROM read_csv('/etc/passwd')",
"SELECT * FROM read_parquet('/etc/passwd')",
"SELECT * FROM read_json_auto('/etc/passwd')",
"SELECT * FROM read_text('/etc/passwd')",
"SELECT * FROM read_blob('/etc/passwd')",
"SELECT * FROM glob('/etc/*')",
"SELECT * FROM duckdb_settings()",
"SELECT * FROM duckdb_secrets()",
"SELECT current_setting('s3_secret_access_key')",
"SELECT getenv('THERMOGRAPH_LAKE_S3_SECRET')",
"SELECT * FROM 'file:///etc/passwd'",
"SELECT * FROM '/etc/passwd'",
# Nested one level down, so a prefix-only check doesn't pass it.
"SELECT * FROM (SELECT * FROM read_csv_auto('/etc/passwd'))",
"WITH x AS (SELECT * FROM glob('/etc/*')) SELECT * FROM x",
])
def test_query_rejects_reads_outside_the_lake_views(lake, sql):
r = lake.post("/query", headers=HDR, json={"sql": sql})
assert r.status_code == 400, f"guard let this through: {sql}"
# Assert the GUARD refused it, not that DuckDB happened to choke on the file
# format. Several of these (read_parquet on a text file, a bare '/etc/passwd')
# error in the engine anyway, so a status-only assertion would still pass with
# the guard deleted -- exactly the "negatives shaped like the regex" trap the
# original four-case test fell into.
detail = r.json()["detail"]
assert not detail.startswith("query failed"), (
f"rejected by the engine, not the guard: {sql} -> {detail}")
def test_query_still_allows_a_block_comment_inside_a_real_select(lake):
"""Defence in depth must not become a false positive: an inline comment in
an otherwise ordinary query is fine."""
r = lake.post("/query", headers=HDR,
json={"sql": "SELECT count(*) /* rows */ FROM manifest"})
assert r.status_code == 200
def test_bucket_mode_uses_a_secret_not_a_readable_setting(monkeypatch, tmp_path):
"""Regression guard for the credential-disclosure shape: the S3 credentials
must be installed as a DuckDB SECRET (opaque to current_setting) rather than
via SET s3_secret_access_key, which hands them back to any caller."""
import lake_app
statements = []
class _FakeCon:
def execute(self, sql, *args):
statements.append(sql)
return self
def fetchone(self):
return ("s3://b/iceberg/era5_daily/metadata/1.metadata.json",)
def close(self):
pass
monkeypatch.setattr(lake_app, "_source", lambda: ("bucket", {
"endpoint": "https://example.com", "bucket": "b", "region": "r",
"access_key": "AK", "secret_key": "SUPER-SECRET-KEY"}))
monkeypatch.setitem(__import__("sys").modules, "duckdb",
type("m", (), {"connect": staticmethod(lambda: _FakeCon())}))
lake_app._connect()
joined = "\n".join(statements)
assert "SECRET" in joined.upper() and "TYPE S3" in joined.upper()
assert "SET s3_secret_access_key" not in joined
assert "SET s3_access_key_id" not in joined
# The key material goes as a bound parameter, never interpolated into SQL.
assert "SUPER-SECRET-KEY" not in joined
# The local filesystem is unreachable in bucket mode, and the caller cannot
# turn it back on.
assert "disabled_filesystems" in joined
assert "lock_configuration" in joined

View file

@ -20,7 +20,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from accounts import api_accounts from accounts import api_accounts
from api import content_routes from api import content_routes
from api import internal_routes from api import internal_routes
from api import narrative_routes
from core import audit from core import audit
from data import climate from data import climate
from notifications import digest from notifications import digest
@ -898,10 +897,6 @@ app.include_router(v2, prefix=f"{BASE}/api/v2")
# service (Stage 3) instead of anything in this process. See # service (Stage 3) instead of anything in this process. See
# backend/api/content_routes.py. # backend/api/content_routes.py.
app.include_router(content_routes.router, prefix=f"{BASE}/api/v2") app.include_router(content_routes.router, prefix=f"{BASE}/api/v2")
# Precomputed plain-English day summaries. Read-only: this router never calls the
# LLM, so mounting it adds no runtime dependency on the inference host — see
# backend/api/narrative_routes.py.
app.include_router(narrative_routes.router, prefix=f"{BASE}/api/v2")
# Internal control surface for the thermograph-daemon Go binary (the gateway # Internal control surface for the thermograph-daemon Go binary (the gateway
# bot + recurring jobs that used to run in _lifespan). Deliberately NOT under # bot + recurring jobs that used to run in _lifespan). Deliberately NOT under
# BASE — the daemon hits THERMOGRAPH_API_BASE_INTERNAL directly, same posture # BASE — the daemon hits THERMOGRAPH_API_BASE_INTERNAL directly, same posture

View file

@ -60,7 +60,7 @@ all on a WireGuard mesh (`10.10.0.0/24`):
| Host | Mesh IP | Public | Runs | | Host | Mesh IP | Public | Runs |
|---|---|---|---| |---|---|---|---|
| **vps1** | `10.10.0.2` | `75.119.132.91`, dev.jinemi.com, dashboard.thermograph.org | Forgejo (git + CI + registry), Grafana + Loki + Alloy, the `emigriffith.dev` portfolio, and **dev** — its own Postgres, plain compose, **mesh-only** (no public DNS, no Caddy site, no TLS) | | **vps1** | `10.10.0.2` | `75.119.132.91`, git.thermograph.org, dashboard.thermograph.org | Forgejo (git + CI + registry), Grafana + Loki + Alloy, the `emigriffith.dev` portfolio, and **dev** — its own Postgres, plain compose, **mesh-only** (no public DNS, no Caddy site, no TLS) |
| **vps2** | `10.10.0.1` | `169.58.46.181`, thermograph.org, beta.thermograph.org | **prod** and **beta** as two separate Docker **Swarm** stacks, Centralis, Postfix, backups | | **vps2** | `10.10.0.1` | `169.58.46.181`, thermograph.org, beta.thermograph.org | **prod** and **beta** as two separate Docker **Swarm** stacks, Centralis, Postfix, backups |
| **desktop** | `10.10.0.3` | — | AI-model hosting (voice-to-text, an upcoming-feature LLM) + flex Swarm capacity. Hosts **no** Thermograph environment — `make dev-up` there is a laptop convenience only | | **desktop** | `10.10.0.3` | — | AI-model hosting (voice-to-text, an upcoming-feature LLM) + flex Swarm capacity. Hosts **no** Thermograph environment — `make dev-up` there is a laptop convenience only |
| phone | — | — | alerts | | phone | — | — | alerts |
@ -84,12 +84,10 @@ tell beta and prod apart by which host it's running on.
Two consequences you will hit within the first week: Two consequences you will hit within the first week:
- **Forgejo is mesh-only.** `dev.jinemi.com` resolves publicly to vps1's - **Forgejo is mesh-only.** `git.thermograph.org` resolves publicly to vps1's
IP, but vps1's Caddy rejects `/v2/*` (the registry API) from outside the IP, but vps1's Caddy rejects `/v2/*` (the registry API) from outside the
mesh. Any host that pulls images needs mesh. Any host that pulls images needs `10.10.0.2 git.thermograph.org` in
`10.10.0.2 dev.jinemi.com git.thermograph.org` in `/etc/hosts` — both names; `/etc/hosts`.
`git.thermograph.org` is the old registry host, still served because
pre-migration image tags carry it.
- **Beta and prod run the same orchestrator now — Swarm, as two stacks on one - **Beta and prod run the same orchestrator now — Swarm, as two stacks on one
box.** Dev is the only environment on compose (`infra/docker-compose.yml`), box.** Dev is the only environment on compose (`infra/docker-compose.yml`),
and it lives alone on vps1. Most of the time you don't care which stack and it lives alone on vps1. Most of the time you don't care which stack

View file

@ -181,7 +181,7 @@ make down
`make install` is not optional on a fresh checkout. Neither compose file `make install` is not optional on a fresh checkout. Neither compose file
carries a `build:` for backend or frontend — each ships as its own registry carries a `build:` for backend or frontend — each ships as its own registry
image — so with no image present compose tries to **pull** image — so with no image present compose tries to **pull**
`dev.jinemi.com/emi/thermograph/backend:local`, a tag published nowhere, `git.thermograph.org/emi/thermograph/backend:local`, a tag published nowhere,
and fails with `403 Forbidden` against a registry you have no login for. and fails with `403 Forbidden` against a registry you have no login for.
`install` builds that tag locally from `backend/Dockerfile` and `install` builds that tag locally from `backend/Dockerfile` and
`frontend/Dockerfile`. `make up` checks both images exist and points you here `frontend/Dockerfile`. `make up` checks both images exist and points you here
@ -218,7 +218,7 @@ database, the ERA5 lake, fleet logs, Grafana, Forgejo, docs and notes, and
Discord. Discord.
```bash ```bash
claude mcp add --transport http centralis https://mcp.jinemi.com/mcp \ claude mcp add --transport http centralis https://mcp.thermograph.org/mcp \
--header "Authorization: Bearer $CENTRALIS_TOKEN" --header "Authorization: Bearer $CENTRALIS_TOKEN"
``` ```

View file

@ -102,7 +102,7 @@ alembic + `/healthz` 200 + a real `/api/v2/place` 200.
Triggers on pushes to `dev`/`main`/`release` touching `backend/**` or Triggers on pushes to `dev`/`main`/`release` touching `backend/**` or
`frontend/**`, and on `v*.*.*` tags. Publishes `frontend/**`, and on `v*.*.*` tags. Publishes
`dev.jinemi.com/Jinemi/thermograph/{backend,frontend}:sha-<12hex>`. `git.thermograph.org/Jinemi/thermograph/{backend,frontend}:sha-<12hex>`.
- **Serialised** (`max-parallel: 1`): one physical runner, and two whole image - **Serialised** (`max-parallel: 1`): one physical runner, and two whole image
builds racing each other buys nothing. builds racing each other buys nothing.
@ -110,9 +110,7 @@ Triggers on pushes to `dev`/`main`/`release` touching `backend/**` or
release tag builds **both** images. That's intended — a release names the release tag builds **both** images. That's intended — a release names the
whole set, not whichever domain moved last. whole set, not whichever domain moved last.
- The registry is mesh-only, so the runner host needs - The registry is mesh-only, so the runner host needs
`10.10.0.2 dev.jinemi.com git.thermograph.org` in `/etc/hosts`. `10.10.0.2 git.thermograph.org` in `/etc/hosts`.
- The registry host itself comes from the Forgejo Actions variable
`REGISTRY_HOST`, not from the workflow file.
### `deploy.yml` — one file, three environments, two services ### `deploy.yml` — one file, three environments, two services

View file

@ -12,7 +12,7 @@ desktop:
| Host | Public | Mesh | Runs | Login | | Host | Public | Mesh | Runs | Login |
|---|---|---|---|---| |---|---|---|---|---|
| **vps1** | `75.119.132.91` / dev.jinemi.com, dashboard.thermograph.org | `10.10.0.2` | Forgejo, Grafana + Loki + Alloy, `emigriffith.dev`, and **dev** (own Postgres, compose, mesh-only) | `agent`, passwordless sudo | | **vps1** | `75.119.132.91` / git.thermograph.org, dashboard.thermograph.org | `10.10.0.2` | Forgejo, Grafana + Loki + Alloy, `emigriffith.dev`, and **dev** (own Postgres, compose, mesh-only) | `agent`, passwordless sudo |
| **vps2** | `169.58.46.181` / thermograph.org, beta.thermograph.org | `10.10.0.1` | **prod** and **beta**, as two co-resident Docker **Swarm** stacks; Centralis; Postfix; backups | `agent`, passwordless sudo | | **vps2** | `169.58.46.181` / thermograph.org, beta.thermograph.org | `10.10.0.1` | **prod** and **beta**, as two co-resident Docker **Swarm** stacks; Centralis; Postfix; backups | `agent`, passwordless sudo |
| **desktop** | — | `10.10.0.3` | AI-model hosting + flex Swarm capacity. No Thermograph environment; `make dev-up` is a laptop convenience there | it's your box | | **desktop** | — | `10.10.0.3` | AI-model hosting + flex Swarm capacity. No Thermograph environment; `make dev-up` is a laptop convenience there | it's your box |

View file

@ -227,12 +227,10 @@ It's executable documentation until state is bootstrapped.
### The registry is mesh-only ### The registry is mesh-only
`dev.jinemi.com` resolves publicly to vps1's IP, but vps1's Caddy rejects `git.thermograph.org` resolves publicly to vps1's IP, but vps1's Caddy rejects
`/v2/*` from outside the WireGuard mesh. Any host that pulls needs `/v2/*` from outside the WireGuard mesh. Any host that pulls needs
`10.10.0.2 dev.jinemi.com git.thermograph.org` in `/etc/hosts`**both** names: `10.10.0.2 git.thermograph.org` in `/etc/hosts`. vps1 itself doesn't — it *is*
the new one is the registry host and the bearer-token realm, the old one still the box.
prefixes every image tag pushed before the migration. vps1 itself doesn't need
the pin — it *is* the box.
### Forgejo runners only have the labels they registered with ### Forgejo runners only have the labels they registered with

View file

@ -6,7 +6,7 @@ pages:
about: about:
title: "About Thermograph: how the weather grades are calculated" title: "About Thermograph: how the weather grades are calculated"
description: >- description: >-
How Thermograph works: ~45 years of ERA5 climate history, a ±7-day How Thermograph works: ~45 years of ERA5 climate history, a &plusmn;7-day
seasonal window, and empirical percentiles that grade each day relative to seasonal window, and empirical percentiles that grade each day relative to
its own location. its own location.
privacy: privacy:
@ -21,7 +21,7 @@ pages:
temperatures by month, all-time records, and how today's weather compares temperatures by month, all-time records, and how today's weather compares
to local history. to local history.
glossary_index: glossary_index:
title: "Weather & climate glossary: heat index, feels-like, percentile, and more" title: "Weather &amp; climate glossary: heat index, feels-like, percentile, and more"
description: >- description: >-
Plain-language definitions of weather and climate terms: climate normal, Plain-language definitions of weather and climate terms: climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, percentile, temperature anomaly, feels-like, heat index, wind chill,

View file

@ -29,7 +29,7 @@ services:
# to the split-era v0.0.2-split-ci, under the retired # to the split-era v0.0.2-split-ci, under the retired
# admin_emi/thermograph-backend/app path, long after both were superseded.) # admin_emi/thermograph-backend/app path, long after both were superseded.)
# Override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific build. # Override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific build.
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${THERMOGRAPH_BACKEND_TEST_TAG:?set it, or run via scripts/backend-for-tests.sh which derives it} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${THERMOGRAPH_BACKEND_TEST_TAG:?set it, or run via scripts/backend-for-tests.sh which derives it}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View file

@ -3,7 +3,6 @@ package contentdata
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"testing" "testing"
) )
@ -110,38 +109,3 @@ func TestLoadRealContentFiles(t *testing.T) {
} }
} }
} }
// pages.yaml's title/description are interpolated as plain strings into
// <title> and <meta name="description"> (base.html.tmpl), so html/template
// escapes them. An HTML entity written in the YAML is therefore escaped a
// second time and the user sees the literal source: "&plusmn;7-day" in the
// SERP snippet, "Weather &amp; climate glossary" in the browser tab. Both
// shipped live until this test existed.
//
// glossary.yaml's `body` is deliberately NOT checked: it is typed
// template.HTML (see glossary_term.html.tmpl) and rendered raw, so entities
// and <b> tags there are correct and must stay.
func TestRealPagesHaveNoDoubleEscapedEntities(t *testing.T) {
dir := filepath.Join("..", "..", "..", "content")
if _, err := os.Stat(filepath.Join(dir, "pages.yaml")); err != nil {
t.Skipf("real content dir not available: %v", err)
}
pages, err := LoadPages(dir)
if err != nil {
t.Fatalf("LoadPages(real): %v", err)
}
// Named (&amp;), decimal (&#177;) and hex (&#xB1;) entity forms.
entity := regexp.MustCompile(`&([a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#[xX][0-9a-fA-F]+);`)
for key, p := range pages {
for field, value := range map[string]string{
"title": p.Title, "description": p.Description,
} {
if m := entity.FindString(value); m != "" {
t.Errorf("pages.yaml[%s].%s contains the HTML entity %q; "+
"write the character literally (this field is escaped at "+
"render time, so the entity reaches the user as source)",
key, field, m)
}
}
}
}

View file

@ -57,7 +57,7 @@ THERMOGRAPH_INTERNAL_TOKEN=
# in its own app repo (thermograph-backend / thermograph-frontend) tagged # in its own app repo (thermograph-backend / thermograph-frontend) tagged
# :local, which is the default here -- only set these to pull a specific # :local, which is the default here -- only set these to pull a specific
# published build instead of building locally. # published build instead of building locally.
# REGISTRY_HOST=dev.jinemi.com # REGISTRY_HOST=git.thermograph.org
# BACKEND_IMAGE_PATH=admin_emi/thermograph-backend/app # BACKEND_IMAGE_PATH=admin_emi/thermograph-backend/app
# BACKEND_IMAGE_TAG=local # BACKEND_IMAGE_TAG=local
# FRONTEND_IMAGE_PATH=admin_emi/thermograph-frontend/app # FRONTEND_IMAGE_PATH=admin_emi/thermograph-frontend/app

View file

@ -26,7 +26,7 @@ rather than which environment happens to live there:
| Host | Public IP | Mesh IP | Role | | Host | Public IP | Mesh IP | Role |
|------|-----------|---------|------| |------|-----------|---------|------|
| **vps1** | `75.119.132.91` | `10.10.0.2` | "Operational programs": Forgejo (git + CI + registry, both at `dev.jinemi.com`; `git.thermograph.org` still served for pre-migration image tags and already-registered runners), Grafana/Loki/Alloy (`dashboard.thermograph.org`), the `emigriffith.dev` portfolio, and the **dev** environment (mesh-only, `10.10.0.2:8137`, no public DNS/Caddy/TLS) | | **vps1** | `75.119.132.91` | `10.10.0.2` | "Operational programs": Forgejo (git + CI + registry; `dev.jinemi.com` canonical, `git.thermograph.org` still served and still used by the registry/mesh pins/runners), Grafana/Loki/Alloy (`dashboard.thermograph.org`), the `emigriffith.dev` portfolio, and the **dev** environment (mesh-only, `10.10.0.2:8137`, no public DNS/Caddy/TLS) |
| **vps2** | `169.58.46.181` | `10.10.0.1` | "The deployed environment": **prod** and **beta**, as two separate Docker Swarm stacks, plus Centralis, Postfix, the backups, and the one shared TimescaleDB instance both stacks use | | **vps2** | `169.58.46.181` | `10.10.0.1` | "The deployed environment": **prod** and **beta**, as two separate Docker Swarm stacks, plus Centralis, Postfix, the backups, and the one shared TimescaleDB instance both stacks use |
| **desktop** | — | `10.10.0.3` | AI-model hosting (voice-to-text, an upcoming-feature LLM) plus flex Swarm-worker capacity. Hosts **no** Thermograph environment — `make dev-up` still works there, but only as a laptop-local rehearsal | | **desktop** | — | `10.10.0.3` | AI-model hosting (voice-to-text, an upcoming-feature LLM) plus flex Swarm-worker capacity. Hosts **no** Thermograph environment — `make dev-up` still works there, but only as a laptop-local rehearsal |
@ -70,7 +70,7 @@ Both VPS boxes are provisioned and reachable as of this writing:
| Host | Role | Public IP | Login | | Host | Role | Public IP | Login |
|------|------|-----------|-------| |------|------|-----------|-------|
| vps2 | Swarm manager; prod AND beta live here (`thermograph.org`, `beta.thermograph.org`) | `169.58.46.181` | `agent` | | vps2 | Swarm manager; prod AND beta live here (`thermograph.org`, `beta.thermograph.org`) | `169.58.46.181` | `agent` |
| vps1 | Swarm worker; Forgejo + Grafana/Loki + dev (`dev.jinemi.com`, `dashboard.thermograph.org`) | `75.119.132.91` | `agent` | | vps1 | Swarm worker; Forgejo + Grafana/Loki + dev (`git.thermograph.org`, `dashboard.thermograph.org`) | `75.119.132.91` | `agent` |
| desktop | Swarm worker, AI-model hosting | (local) | (already have access) | | desktop | Swarm worker, AI-model hosting | (local) | (already have access) |
``` ```
@ -179,9 +179,8 @@ The GitHub → Forgejo cutover is complete; this records what was done:
failed login attempt, not just config inspection) failed login attempt, not just config inspection)
- [x] `docker node ls` (from vps2, the manager) shows all three nodes `Ready` - [x] `docker node ls` (from vps2, the manager) shows all three nodes `Ready`
- [x] Swarm ports (2377/7946/4789) unreachable from outside the WireGuard tunnel - [x] Swarm ports (2377/7946/4789) unreachable from outside the WireGuard tunnel
- [x] `https://dev.jinemi.com` serves Forgejo over TLS (and - [x] `https://git.thermograph.org` serves Forgejo over TLS; `/v2/` registry
`https://git.thermograph.org` still does, off the same site block); API is 403 from off-mesh (firewalled to the WireGuard CIDR)
`/v2/` registry API is 403 from off-mesh (firewalled to the WireGuard CIDR)
- [x] A test PR flow verified: required `build` check runs → explicit squash - [x] A test PR flow verified: required `build` check runs → explicit squash
merge → deploy lands on the branch's mapped environment merge → deploy lands on the branch's mapped environment
- [x] GitHub retired as git host + CI; kept only as a read-only history mirror - [x] GitHub retired as git host + CI; kept only as a read-only history mirror

View file

@ -12,11 +12,10 @@ Two VPS boxes plus the operator's desktop, on one WireGuard mesh, named by
ROLE rather than by environment: ROLE rather than by environment:
- **vps1**`75.119.132.91`, mesh `10.10.0.2`. "Operational programs": Forgejo - **vps1**`75.119.132.91`, mesh `10.10.0.2`. "Operational programs": Forgejo
(git + CI + registry; `dev.jinemi.com` is canonical on both axes — Forgejo's (git + CI + registry; `dev.jinemi.com` is canonical — Forgejo's `ROOT_URL`
`ROOT_URL` *and* the registry host in every image name — while and `git.thermograph.org` is still served off the same Caddy site block and
`git.thermograph.org` is still served off the same Caddy site block for still load-bearing for the registry, mesh pins and runner URLs, so don't
pre-migration image tags and already-registered runners, so don't retire it; retire it; see `deploy/forgejo/README.md`), Grafana/Loki/Alloy
see `deploy/forgejo/README.md`), Grafana/Loki/Alloy
(`dashboard.thermograph.org`), the `emigriffith.dev` portfolio site, and the (`dashboard.thermograph.org`), the `emigriffith.dev` portfolio site, and the
**dev** environment (`/opt/thermograph-dev`, tracks `dev`), with its own **dev** environment (`/opt/thermograph-dev`, tracks `dev`), with its own
Postgres container. Dev is mesh-only here — published on `10.10.0.2:8137`, Postgres container. Dev is mesh-only here — published on `10.10.0.2:8137`,

View file

@ -5,7 +5,7 @@ the **`dev`** branch continuously deploys to the **dev environment on vps1**
(`75.119.132.91`, mesh `10.10.0.2`) — `/opt/thermograph-dev`, a normal fleet (`75.119.132.91`, mesh `10.10.0.2`) — `/opt/thermograph-dev`, a normal fleet
checkout reached over SSH exactly like beta and prod, **not** a stack on the checkout reached over SSH exactly like beta and prod, **not** a stack on the
operator's desktop or LAN any more. Git hosting and CI are self-hosted operator's desktop or LAN any more. Git hosting and CI are self-hosted
**Forgejo** (`dev.jinemi.com`, also on vps1, reachable at **Forgejo** (`git.thermograph.org`, also on vps1, reachable at
`http://10.10.0.2:3080` over the WireGuard mesh) — GitHub is retired. `http://10.10.0.2:3080` over the WireGuard mesh) — GitHub is retired.
``` ```
@ -125,14 +125,13 @@ bash deploy/forgejo/register-lan-runner.sh <forgejo_url> <registration_token>
Registry pushes (`build-push.yml`) run against the runner host's own Registry pushes (`build-push.yml`) run against the runner host's own
automounted Docker daemon, so it's that **host's** own resolver that needs to automounted Docker daemon, so it's that **host's** own resolver that needs to
route `dev.jinemi.com` over the mesh, not anything settable from a job 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 container. If registry pushes ever start failing with an HTTP/TLS mismatch or
a 403 on `/v2/`, check `getent hosts dev.jinemi.com` on the runner host — a 403 on `/v2/`, check `getent hosts git.thermograph.org` on the runner host —
it needs to resolve to vps1's WireGuard IP (`10.10.0.2`), typically via a it needs to resolve to vps1's WireGuard IP (`10.10.0.2`), typically via a
`/etc/hosts` line, not the public one (a non-issue if the runner is co-located `/etc/hosts` line, not the public one (a non-issue if the runner is co-located
with Forgejo on vps1 itself, since the name then resolves to itself either with Forgejo on vps1 itself, since `git.thermograph.org` then resolves to
way). Check `git.thermograph.org` the same way: it is still served, and a itself either way).
rollback to an image tag pushed before the domain migration dials it.
## The dev environment (Docker Compose, on vps1) ## The dev environment (Docker Compose, on vps1)

View file

@ -24,7 +24,7 @@ own loopback Caddy LB fronts it with TLS.** Credentials are keyed by **host**
(`VPS2_SSH_*`), not by environment — vps2 answers to both prod and beta, so the (`VPS2_SSH_*`), not by environment — vps2 answers to both prod and beta, so the
environment is passed as data (`THERMOGRAPH_ENV`), not baked into which secret environment is passed as data (`THERMOGRAPH_ENV`), not baked into which secret
is used. (GitHub is retired/archived — Forgejo, self-hosted at is used. (GitHub is retired/archived — Forgejo, self-hosted at
`dev.jinemi.com` on **vps1** over the WireGuard mesh, is now the sole git `git.thermograph.org` on **vps1** over the WireGuard mesh, is now the sole git
host and CI.) host and CI.)
Files that make this work: Files that make this work:
@ -49,17 +49,14 @@ gitignored cache under `data/cache/`, so a `git pull` in one never touches the
other's. other's.
**Any deploy host needs a `/etc/hosts` entry for the registry.** **Any deploy host needs a `/etc/hosts` entry for the registry.**
`dev.jinemi.com`'s public DNS resolves to **vps1's** public IP, and `git.thermograph.org`'s public DNS resolves to **vps1's** public IP, and
vps1's own Caddy rejects `/v2/*` (the registry API) from anything outside the vps1'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 WireGuard mesh (10.10.0.0/24) — confirmed live: `docker login`/`pull` from
vps2 failed with a 403 until adding `10.10.0.2 dev.jinemi.com git.thermograph.org` vps2 failed with a 403 until adding `10.10.0.2 git.thermograph.org` to
to `/etc/hosts` (10.10.0.2 is vps1's mesh IP; every deploy host is already on `/etc/hosts` (10.10.0.2 is vps1's mesh IP; every deploy host is already on
the mesh, this is purely a DNS-routing gap, not a connectivity one). Pin the mesh, this is purely a DNS-routing gap, not a connectivity one). vps1
**both** names: `dev.jinemi.com` is the registry host and the bearer-token itself doesn't need this — it's the box Forgejo runs on, so `git.thermograph.org`
realm, and `git.thermograph.org` still prefixes every image tag pushed before just resolves to itself either way. `build-push.yml`'s own header comment
the domain migration, so a rollback to one of those dials it directly. vps1
itself doesn't need this — it's the box Forgejo runs on, so both names
just resolve to it either way. `build-push.yml`'s own header comment
documents the same requirement for the CI runner host. documents the same requirement for the CI runner host.
**Current production layout**: prod's Caddy config owns **`thermograph.org`** **Current production layout**: prod's Caddy config owns **`thermograph.org`**

View file

@ -2,16 +2,11 @@
# #
# vps1 serves: # vps1 serves:
# emigriffith.dev -> static portfolio site (from disk) # emigriffith.dev -> static portfolio site (from disk)
# dev.jinemi.com -> Forgejo, CANONICAL: both its ROOT_URL and the # dev.jinemi.com -> Forgejo, CANONICAL (Forgejo's ROOT_URL)
# registry host in every image name # git.thermograph.org -> Forgejo, same site block — still served and
# git.thermograph.org -> Forgejo, same site block — still served, now # still needed: the registry host in image names,
# only for already-pushed image tags carrying the # the mesh /etc/hosts pins and the runners' URLs
# old prefix and for runners registered against # all use it (see deploy/forgejo/caddy-git.conf)
# it (see deploy/forgejo/caddy-git.conf)
#
# Do not confuse `dev.jinemi.com` (Forgejo — git, CI, registry) with
# `dev.thermograph.org` (the dev *application* environment, further down this
# file). Same box, same word, unrelated services.
# dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf) # dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf)
# #
# and hosts the **dev** environment at dev.thermograph.org (below). # and hosts the **dev** environment at dev.thermograph.org (below).

View file

@ -4,7 +4,7 @@
# thermograph.org -> prod (loopback LB on 127.0.0.1:8137 / :8080) # thermograph.org -> prod (loopback LB on 127.0.0.1:8137 / :8080)
# beta.thermograph.org -> beta (loopback LB on 127.0.0.1:8237 / :8180) # beta.thermograph.org -> beta (loopback LB on 127.0.0.1:8237 / :8180)
# #
# and Centralis at mcp.jinemi.com, which is provisioned separately. # and Centralis at mcp.thermograph.org, which is provisioned separately.
# #
# Each domain's A/AAAA record must already point here — Caddy provisions a # Each domain's A/AAAA record must already point here — Caddy provisions a
# Let's Encrypt cert on first request and auto-renews. Nothing else to do for # Let's Encrypt cert on first request and auto-renews. Nothing else to do for

View file

@ -215,7 +215,7 @@ fi
# a tag from -- the caller (the deploying repo's deploy.yml) exports its own # 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 # service's tag (BACKEND_IMAGE_TAG or FRONTEND_IMAGE_TAG = sha-<12 hex> of the
# app commit, or a semver tag). # app commit, or a semver tag).
REGISTRY_HOST="${REGISTRY_HOST:-dev.jinemi.com}" REGISTRY_HOST="${REGISTRY_HOST:-git.thermograph.org}"
export REGISTRY_HOST BACKEND_IMAGE_PATH FRONTEND_IMAGE_PATH export REGISTRY_HOST BACKEND_IMAGE_PATH FRONTEND_IMAGE_PATH
# Load the last-deployed tag for BOTH services first, so a single-service roll # Load the last-deployed tag for BOTH services first, so a single-service roll
@ -328,7 +328,7 @@ case " ${TARGETS[*]} " in
# then always found no daemon binary in a Postgres image and dropped # then always found no daemon binary in a Postgres image and dropped
# daemon from EVERY backend deploy, regardless of what the real backend # daemon from EVERY backend deploy, regardless of what the real backend
# image contained -- reproduced and confirmed against beta directly. # image contained -- reproduced and confirmed against beta directly.
daemon_img="${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG}" daemon_img="${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG}"
if ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then if ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then
echo "==> $daemon_img predates the daemon binary; rolling without the daemon service this run" echo "==> $daemon_img predates the daemon binary; rolling without the daemon service this run"
kept=() kept=()

View file

@ -70,7 +70,7 @@ web port publishes to `127.0.0.1:3080` only (host-local), and vps1's
*existing* Caddy gets one more site block reverse-proxying to it — same *existing* Caddy gets one more site block reverse-proxying to it — same
pattern as its other site blocks, same automatic-HTTPS. pattern as its other site blocks, same automatic-HTTPS.
1. Point the Forgejo domain (default `dev.jinemi.com`; override with 1. Point the Forgejo domain (default `git.thermograph.org`; override with
`FORGEJO_DOMAIN=...` before `docker stack deploy`) at **vps1's** public IP `FORGEJO_DOMAIN=...` before `docker stack deploy`) at **vps1's** public IP
— that's where the task actually runs, not vps2's or the desktop's. — that's where the task actually runs, not vps2's or the desktop's.
2. Append `deploy/forgejo/caddy-git.conf` to vps1's `/etc/caddy/Caddyfile`, 2. Append `deploy/forgejo/caddy-git.conf` to vps1's `/etc/caddy/Caddyfile`,
@ -89,54 +89,26 @@ pattern as its other site blocks, same automatic-HTTPS.
(`FORGEJO_DOMAIN` in `docker-stack.yml`), so it is the name Forgejo generates in (`FORGEJO_DOMAIN` in `docker-stack.yml`), so it is the name Forgejo generates in
clone URLs, the Google OAuth callback, webhook payload URLs and mail links. clone URLs, the Google OAuth callback, webhook payload URLs and mail links.
**Registry: done in this repo, and it needs host-side steps to match.** Every **Registry: deliberately not done.** Renaming the registry host is a separate
image name, `REGISTRY_HOST` default, runner label and `--add-host` pin under migration from renaming the web UI, and the two need not happen together.
version control now says `dev.jinemi.com`. None of that is self-applying — see
"Host-side steps" below before expecting a build to push.
Both names address the *same* Forgejo and therefore the same packages. A tag
pushed as `git.thermograph.org/jinemi/thermograph/backend:sha-abc` is pullable
as `dev.jinemi.com/jinemi/thermograph/backend:sha-abc` — the host part is only
how you reach the registry, not part of the package's identity. That is what
makes this migration safe to do in one step instead of a dual-push period: no
image needs re-pushing, and a rollback to an old tag still resolves.
`caddy-git.conf` lists both names on a *single* site block. That is deliberate, `caddy-git.conf` lists both names on a *single* site block. That is deliberate,
not cosmetic: the `/v2/*` mesh-only matcher is per-block, so a second block for not cosmetic: the `/v2/*` mesh-only matcher is per-block, so a second block for
either name would re-expose the registry API publicly and undo hazard #15. either name would re-expose the registry API publicly and undo hazard #15.
Whatever else changes, keep the two names in one block. Whatever else changes, keep the two names in one block.
**`git.thermograph.org` must stay served.** The list of reasons is shorter than **`git.thermograph.org` must stay served.** It is not a courtesy redirect for
it was, but it is not empty, and neither remaining item is a bookmark: old bookmarks — CI resolves it:
- image tags already pushed under the old prefix name that host, so a rollback - images are named by registry host, and the `git.thermograph.org/` prefix is
to one asks for `git.thermograph.org/...` even though nothing builds that baked into the runner labels (`register-lan-runner.sh`,
name any more; `runner-vps2/README.md`), the CI-runner image (`ci-runner/Dockerfile`),
`REGISTRY_HOST` in `infra/.env.example`, and every already-pushed tag;
- every mesh client in "Registry access from mesh clients" below pins
`git.thermograph.org` to `10.10.0.2` in `/etc/hosts`, which is what makes
Caddy's `/v2/*` matcher see a mesh source IP instead of returning 403;
- registered runners hold the instance URL they registered with - registered runners hold the instance URL they registered with
(`https://git.thermograph.org`); they keep working while that name resolves, (`https://git.thermograph.org`); they keep working while that name resolves.
and stop the moment it doesn't. Re-registering them is the only way to retire
the name, and it is not required for this migration.
### Host-side steps
None of these live in the repo, and CI will not do them for you. In order:
1. **`/etc/hosts` on every mesh client** — pin `dev.jinemi.com` as well as
`git.thermograph.org` (both, for the reasons under "Registry access from
mesh clients" below). A client that resolves the new name publicly gets a
403 from the `/v2/*` matcher, which reads exactly like a bad credential.
2. **`docker login dev.jinemi.com`** on each host that pushes or pulls — the
CI runners, vps2 (prod and beta), the desktop. A login against the old name
does **not** carry over: docker keys stored credentials by registry host.
3. **The Forgejo Actions variable `REGISTRY_HOST`**`dev.jinemi.com`
(Site Administration → Actions → Variables, or the repo's own). This is what
`build-push.yml` reads; the defaults in this repo are fallbacks for by-hand
runs, so leaving the variable stale silently keeps CI on the old name.
4. **Re-register the runners** against `https://dev.jinemi.com`, *optionally*
see above. Until you do, leave `git.thermograph.org` served.
Verify with a push before assuming: the failure mode for a missed step 1 or 2
is `unauthorized: reqPackageAccess`, which names neither.
### Before changing ROOT_URL again ### Before changing ROOT_URL again
@ -167,39 +139,37 @@ effect when someone re-runs `docker stack deploy` by hand on the manager (vps2)
Any node that needs `docker login`/push/pull against the registry (the CI Any node that needs `docker login`/push/pull against the registry (the CI
runner building/pushing images, any Swarm node pulling them, prod or beta on runner building/pushing images, any Swarm node pulling them, prod or beta on
vps2 pulling app images) must reach Forgejo **over the WireGuard tunnel**, not vps2 pulling app images) must reach `git.thermograph.org` **over the
vps1's public IP — otherwise Caddy's `/v2/*` block above refuses the WireGuard tunnel**, not vps1's public IP — otherwise Caddy's `/v2/*` block
connection. Public DNS resolves both names to vps1's public IP, so add an above refuses the connection. Public DNS resolves the domain to vps1's public
`/etc/hosts` override on each such node pinning them to vps1's WireGuard IP, so add a `/etc/hosts` override on each such node pinning it to vps1's
address instead: WireGuard address instead:
``` ```
echo "10.10.0.2 dev.jinemi.com git.thermograph.org" | sudo tee -a /etc/hosts echo "10.10.0.2 git.thermograph.org" | sudo tee -a /etc/hosts
``` ```
(`10.10.0.2` is vps1's WG address per `deploy/swarm/README.md`'s peer (`10.10.0.2` is vps1's WG address per `deploy/swarm/README.md`'s peer
numbering — adjust if you assigned it differently.) The git/web UI keeps numbering — adjust if you assigned it differently.) The git/web UI keeps
working normally for everyone else since only `/v2/*` is restricted. working normally for everyone else since only `/v2/*` is restricted.
**Both names, not just the one in the image.** `dev.jinemi.com` now carries two **Pin the canonical name too, not just the image host.** Forgejo derives the
independent jobs and needs the pin for each: it is the host in every image registry's bearer-token realm from `ROOT_URL`, so a client that already
name, *and* it is `ROOT_URL`, from which Forgejo derives the registry's resolves `git.thermograph.org` over the mesh is still sent to
bearer-token realm — so a client is sent to `https://<ROOT_URL host>/v2/token` `https://<ROOT_URL host>/v2/token` to collect a token. When `ROOT_URL` became
to collect a token no matter which name it dialled. `git.thermograph.org` stays `dev.jinemi.com`, that second request took the public route, the `/v2/*`
pinned for a third reason: image tags pushed before the migration name it, and matcher above answered 403, and docker fell back to anonymous — every push then
a rollback to one of those dials it directly. failed with `unauthorized: reqPackageAccess`, which reads exactly like a
revoked token or a missing scope. It is neither. Pin both names:
This is not hypothetical. When `ROOT_URL` first became `dev.jinemi.com` while ```
images were still named `git.thermograph.org`, the token request took the echo "10.10.0.2 git.thermograph.org dev.jinemi.com" | sudo tee -a /etc/hosts
public route, the `/v2/*` matcher answered 403, and docker fell back to ```
anonymous — every push failed with `unauthorized: reqPackageAccess`, which
reads exactly like a revoked token or a missing scope. It was neither. Pinning
one name and not the other reproduces it in either direction.
The realm moves whenever `ROOT_URL` does, so read it rather than assuming: The realm moves whenever `ROOT_URL` does, so read it rather than assuming:
```sh ```sh
curl -sI https://dev.jinemi.com/v2/ | grep -i www-authenticate curl -sI https://git.thermograph.org/v2/ | grep -i www-authenticate
``` ```
These `/etc/hosts` pins are host state. Nothing in this repo writes them, so a These `/etc/hosts` pins are host state. Nothing in this repo writes them, so a
@ -289,14 +259,14 @@ bug hit during the frontend Go rewrite). `ci-runner` adds `docker-ce-cli` +
`git`/`python3`/`python3-yaml` for the other jobs that need them `git`/`python3`/`python3-yaml` for the other jobs that need them
(`shell-lint`, `observability-validate`). (`shell-lint`, `observability-validate`).
Current tag: `dev.jinemi.com/jinemi/thermograph/ci-runner:v2` (`v1` is Current tag: `git.thermograph.org/jinemi/thermograph/ci-runner:v2` (`v1` is
broken — do not register any runner against it). Rebuild/push (requires a PAT broken — do not register any runner against it). Rebuild/push (requires a PAT
with `write:package` scope — the embedded git-remote token lacks it, same with `write:package` scope — the embedded git-remote token lacks it, same
requirement documented in `build-push.yml`): requirement documented in `build-push.yml`):
```bash ```bash
docker build -t dev.jinemi.com/jinemi/thermograph/ci-runner:vN deploy/forgejo/ci-runner docker build -t git.thermograph.org/jinemi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
docker push dev.jinemi.com/jinemi/thermograph/ci-runner:vN docker push git.thermograph.org/jinemi/thermograph/ci-runner:vN
``` ```
`register-lan-runner.sh`'s `LABELS` default points at the current tag, so `register-lan-runner.sh`'s `LABELS` default points at the current tag, so
@ -321,9 +291,8 @@ independently.
```bash ```bash
docker service ls # forgejo_db, forgejo_forgejo both Running, 1/1 docker service ls # forgejo_db, forgejo_forgejo both Running, 1/1
curl -I https://dev.jinemi.com/ # 200, valid cert (Caddy's, not a new one) curl -I https://git.thermograph.org/ # 200, valid cert (Caddy's, not a new one)
curl -I https://dev.jinemi.com/v2/ # 403 from anywhere off the WireGuard mesh curl -I https://git.thermograph.org/v2/ # 403 from anywhere off the WireGuard mesh
curl -I https://git.thermograph.org/ # 200 too — the alias must stay served
# On the runner host, after registering the runner: # On the runner host, after registering the runner:
systemctl --user status forgejo-runner # active, both labels registered systemctl --user status forgejo-runner # active, both labels registered
``` ```

View file

@ -1,4 +1,4 @@
# Only the block below (from "dev.jinemi.com, git.thermograph.org {") goes into # Only the block below (from "git.thermograph.org, dev.jinemi.com {") goes into
# the live Caddyfile — append just that part, not this instructional header, or # the live Caddyfile — append just that part, not this instructional header, or
# it ends up as a confusing orphaned comment in production config with no # it ends up as a confusing orphaned comment in production config with no
# surrounding context (docker-stack.yml isn't visible from there). # surrounding context (docker-stack.yml isn't visible from there).
@ -14,17 +14,14 @@
# both. Splitting dev.jinemi.com into its own block serves the same Forgejo # both. Splitting dev.jinemi.com into its own block serves the same Forgejo
# with the registry API open to the public internet. # with the registry API open to the public internet.
# #
# dev.jinemi.com is CANONICAL, now on both axes — it is Forgejo's ROOT_URL # dev.jinemi.com is CANONICAL — it is Forgejo's ROOT_URL (docker-stack.yml,
# (docker-stack.yml, FORGEJO_DOMAIN), so it is the name Forgejo puts in clone # FORGEJO_DOMAIN), so it is the name Forgejo puts in clone URLs, the OAuth
# URLs, the OAuth callback, webhook payload URLs and mail links; and it is the # callback, webhook payload URLs and mail links.
# registry host in every image name this repo builds or pulls.
# #
# git.thermograph.org is kept as a served alias, NOT as dead weight, though the # git.thermograph.org is kept as a served alias, NOT as dead weight: the
# reason narrowed when the registry moved. What still needs it: image tags # registry host baked into image names, the mesh /etc/hosts pins and the
# already pushed under the old prefix (a rollback to one names that host), and # runners' registered instance URL all still say git.thermograph.org. Removing
# runners registered against `https://git.thermograph.org`, which hold the # this name from the block breaks CI, not just old bookmarks. Both names get
# instance URL they registered with. Removing this name from the block breaks a
# rollback and de-registers runners — not just old bookmarks. Both names get
# their own Let's Encrypt cert automatically (HTTP-01); both A records point # their own Let's Encrypt cert automatically (HTTP-01); both A records point
# at vps1. See README.md, "Migrating the domain". # at vps1. See README.md, "Migrating the domain".
# #
@ -36,7 +33,7 @@
# --- copy from here down into /etc/caddy/Caddyfile --- # --- copy from here down into /etc/caddy/Caddyfile ---
dev.jinemi.com, git.thermograph.org { git.thermograph.org, dev.jinemi.com {
encode zstd gzip encode zstd gzip
@registry_external { @registry_external {

View file

@ -19,9 +19,9 @@
# class, and skips the per-job install entirely. # class, and skips the per-job install entirely.
# #
# Build/push (manual — see ../README.md for when to rebuild): # Build/push (manual — see ../README.md for when to rebuild):
# docker build -t dev.jinemi.com/jinemi/thermograph/ci-runner:vN \ # docker build -t git.thermograph.org/jinemi/thermograph/ci-runner:vN \
# infra/deploy/forgejo/ci-runner # infra/deploy/forgejo/ci-runner
# docker push dev.jinemi.com/jinemi/thermograph/ci-runner:vN # docker push git.thermograph.org/jinemi/thermograph/ci-runner:vN
# #
# Cutting over the live runner to a new tag means editing the `labels` array # Cutting over the live runner to a new tag means editing the `labels` array
# in ~/forgejo-runner/.runner on the runner host directly (same runner # in ~/forgejo-runner/.runner on the runner host directly (same runner

View file

@ -20,7 +20,7 @@
# box). A second reverse proxy binding those same ports would either fail to # box). A second reverse proxy binding those same ports would either fail to
# start or fight Caddy. Instead: forgejo's web port publishes to # start or fight Caddy. Instead: forgejo's web port publishes to
# 127.0.0.1:3080 only (host-local, mode: host), and vps1's existing Caddy gets # 127.0.0.1:3080 only (host-local, mode: host), and vps1's existing Caddy gets
# a new site block reverse-proxying dev.jinemi.com -> 127.0.0.1:3080, # a new site block reverse-proxying git.thermograph.org -> 127.0.0.1:3080,
# same pattern as its other blocks. TLS is Caddy's existing automatic-HTTPS # same pattern as its other blocks. TLS is Caddy's existing automatic-HTTPS
# (HTTP-01), not a second ACME flow. # (HTTP-01), not a second ACME flow.
# #
@ -82,8 +82,7 @@ services:
FORGEJO__database__USER: forgejo FORGEJO__database__USER: forgejo
# Canonical domain. Forgejo has exactly ONE ROOT_URL and derives every # Canonical domain. Forgejo has exactly ONE ROOT_URL and derives every
# absolute URL from it — clone URLs, the Google OAuth callback, webhook # absolute URL from it — clone URLs, the Google OAuth callback, webhook
# payload URLs, mail links, and the registry's bearer-token realm. # payload URLs, mail links. git.thermograph.org is still served (Caddy
# git.thermograph.org is still served (Caddy
# answers for both names off one site block, deploy/forgejo/caddy-git.conf) # answers for both names off one site block, deploy/forgejo/caddy-git.conf)
# and still works for browsing, git-over-HTTP and the API; it is simply no # and still works for browsing, git-over-HTTP and the API; it is simply no
# longer the name Forgejo generates. # longer the name Forgejo generates.
@ -95,12 +94,9 @@ services:
# git.thermograph.org one are registered; verify with a probe before # git.thermograph.org one are registered; verify with a probe before
# changing this again (deploy/forgejo/README.md, "Migrating the domain"). # changing this again (deploy/forgejo/README.md, "Migrating the domain").
# #
# The registry host in image names and the mesh /etc/hosts pins now say # NOT changed by this: the registry host baked into image names, the mesh
# dev.jinemi.com too, so ROOT_URL and the registry name agree again — which # /etc/hosts pins, and the runners' registered instance URL all still say
# matters, because Forgejo derives the registry's bearer-token realm from # git.thermograph.org. That is a separate migration.
# ROOT_URL. Still on the old name: runners registered against
# https://git.thermograph.org (they hold the URL they registered with) and
# image tags already pushed under the old prefix.
FORGEJO__server__DOMAIN: "${FORGEJO_DOMAIN:-dev.jinemi.com}" FORGEJO__server__DOMAIN: "${FORGEJO_DOMAIN:-dev.jinemi.com}"
FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN:-dev.jinemi.com}/" FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN:-dev.jinemi.com}/"
FORGEJO__server__SSH_PORT: "2222" FORGEJO__server__SSH_PORT: "2222"

View file

@ -40,7 +40,7 @@ set -euo pipefail
FORGEJO_URL="${1:?usage: $0 <forgejo_url> <registration_token>}" FORGEJO_URL="${1:?usage: $0 <forgejo_url> <registration_token>}"
TOKEN="${2:?}" TOKEN="${2:?}"
RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}" RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}"
LABELS="${LABELS:-docker:docker://dev.jinemi.com/jinemi/thermograph/ci-runner:v2,thermograph-lan}" LABELS="${LABELS:-docker:docker://git.thermograph.org/jinemi/thermograph/ci-runner:v2,thermograph-lan}"
echo "==> Stopping and disabling the old GitHub Actions runner service, if present" echo "==> Stopping and disabling the old GitHub Actions runner service, if present"
systemctl --user stop github-actions-runner 2>/dev/null || true systemctl --user stop github-actions-runner 2>/dev/null || true

View file

@ -26,14 +26,12 @@ documentation one.
## Prerequisites ## Prerequisites
- vps2's docker daemon is logged in to `dev.jinemi.com` - vps2's docker daemon is logged in to `git.thermograph.org` (it is —
(`/root/.docker/config.json`), so the daemon can pull the job image. A login `/root/.docker/config.json`), so the daemon can pull the job image.
against the old `git.thermograph.org` does not carry over — docker keys - `git.thermograph.org` resolves to the **mesh** address from vps2 for registry
credentials by registry host, so this needs its own `docker login`.
- `dev.jinemi.com` resolves to the **mesh** address from vps2 for registry
traffic. Job containers get this via `--add-host` in `config.yaml`; the host traffic. Job containers get this via `--add-host` in `config.yaml`; the host
daemon needs the same pin in `/etc/hosts`. daemon already has it.
- The job image exists: `dev.jinemi.com/jinemi/thermograph/ci-runner:v2`. - The job image exists: `git.thermograph.org/jinemi/thermograph/ci-runner:v2`.
Built from `../ci-runner/Dockerfile`; see that file for when to rebuild. Built from `../ci-runner/Dockerfile`; see that file for when to rebuild.
## Where it is deployed, and why not from the checkout ## Where it is deployed, and why not from the checkout
@ -91,20 +89,14 @@ docker run --rm -it \
-v "$PWD/data:/data" -w /data --user root \ -v "$PWD/data:/data" -w /data --user root \
code.forgejo.org/forgejo/runner:6.3.1 \ code.forgejo.org/forgejo/runner:6.3.1 \
forgejo-runner register --no-interactive \ forgejo-runner register --no-interactive \
--instance https://dev.jinemi.com \ --instance https://git.thermograph.org \
--token "$REG_TOKEN" \ --token "$REG_TOKEN" \
--name thermograph-vps2 \ --name thermograph-vps2 \
--labels 'docker:docker://dev.jinemi.com/jinemi/thermograph/ci-runner:v2' --labels 'docker:docker://git.thermograph.org/jinemi/thermograph/ci-runner:v2'
unset REG_TOKEN unset REG_TOKEN
``` ```
The runner registered before the domain migration holds
`https://git.thermograph.org` as its instance URL and keeps working while Caddy
serves that name (it does — see `../caddy-git.conf`). Re-registering with the
command above is how it moves to the canonical name; it is not required, and it
costs a fresh registration token and a runner restart.
That writes `data/.runner`, which holds the **long-lived** runner token. It is That writes `data/.runner`, which holds the **long-lived** runner token. It is
`0600` and must stay out of git — `data/` is gitignored for exactly this reason. `0600` and must stay out of git — `data/` is gitignored for exactly this reason.
Re-running `register` with a fresh registration token replaces it; the old Re-running `register` with a fresh registration token replaces it; the old
@ -155,6 +147,5 @@ only, and the job container has no other way to learn the mesh address.
from the compose file. from the compose file.
**The runner is up but `last_online` is stale.** It cannot reach **The runner is up but `last_online` is stale.** It cannot reach
the instance URL it registered with (`https://dev.jinemi.com`, or `https://git.thermograph.org` — check egress from vps2 and that Forgejo on vps1
`https://git.thermograph.org` for a runner registered before the migration) — is serving.
check egress from vps2 and that Forgejo on vps1 is serving that name.

View file

@ -25,26 +25,18 @@ runner:
shutdown_timeout: 3h shutdown_timeout: 3h
container: container:
# 1) --add-host: both Forgejo names resolve publicly to vps1, but vps1's # 1) --add-host: git.thermograph.org resolves publicly to vps1, but vps1's
# Caddy rejects the /v2/* registry API from off-mesh. Job containers that # Caddy rejects the /v2/* registry API from off-mesh. Job containers that
# docker-pull or docker-push therefore need the MESH address, and DNS will # docker-pull or docker-push therefore need the MESH address, and DNS will
# not give it to them. Same line the desktop runner carries; the value is # not give it to them. Same line the desktop runner carries; the value is
# the same from vps2 because both boxes are on 10.10.0.0/24. # the same from vps2 because both boxes are on 10.10.0.0/24.
# #
# BOTH names are pinned, and that is not belt-and-braces. dev.jinemi.com is
# the registry host in image names AND Forgejo's ROOT_URL, from which the
# registry's bearer-token realm is derived -- so a pull needs it twice over.
# git.thermograph.org stays pinned because already-pushed tags carry it as
# their prefix; unpinned, a rollback to such a tag takes the public route
# and gets a 403 that reads like a credential failure. See
# ../README.md, "Migrating the domain".
#
# 2) --cpus/--memory: vps2 runs prod. Without a ceiling a runaway job (a # 2) --cpus/--memory: vps2 runs prod. Without a ceiling a runaway job (a
# `docker build` that fans out, a test that leaks) competes with the # `docker build` that fans out, a test that leaks) competes with the
# production app for the same 6 cores. These bound the JOB container, which # production app for the same 6 cores. These bound the JOB container, which
# is a sibling of the runner rather than its child, so the runner's own # is a sibling of the runner rather than its child, so the runner's own
# compose limits do not cover it -- this is the only lever that does. # compose limits do not cover it -- this is the only lever that does.
options: --add-host=dev.jinemi.com:10.10.0.2 --add-host=git.thermograph.org:10.10.0.2 --cpus=2 --memory=4g options: --add-host=git.thermograph.org:10.10.0.2 --cpus=2 --memory=4g
# Jobs may mount NOTHING from the host. The workflows here do not need to: # Jobs may mount NOTHING from the host. The workflows here do not need to:
# they check out into the job container's own workspace and reach the estate # they check out into the job container's own workspace and reach the estate

View file

@ -7,6 +7,10 @@ CENTRALIS_TRANSCRIPT_CHANNEL: ENC[AES256_GCM,data:poxdL903opQV5waTCnO4wM3eIg==,i
CENTRALIS_VOICE_SUMMARY_CHANNEL: ENC[AES256_GCM,data:Ztvy9w8wpmEcROyRDJOosBJlWw==,iv:iugYisN38csVNIZamZ84mzXaPTABxU+I2yZ+CzL/ddQ=,tag:wNGJcoYN2obnoKY7uchYiw==,type:str] CENTRALIS_VOICE_SUMMARY_CHANNEL: ENC[AES256_GCM,data:Ztvy9w8wpmEcROyRDJOosBJlWw==,iv:iugYisN38csVNIZamZ84mzXaPTABxU+I2yZ+CzL/ddQ=,tag:wNGJcoYN2obnoKY7uchYiw==,type:str]
DISCORD_TOKEN: ENC[AES256_GCM,data:hdrjzSqwKmUdmdLl8dOMUPzLxfZf3H4Z6D9b+PIn/nzapQAPECihrxHZswayinYV3nbcZKj2Qh3Aw2jzaP0OpYM6HFdZmosA,iv:nE4utRtwNCDEWOKjTaobpXJHWkXY6LQE1EvHGH+tUH0=,tag:+3UC7N8B1WCCPzBzmB4yog==,type:str] DISCORD_TOKEN: ENC[AES256_GCM,data:hdrjzSqwKmUdmdLl8dOMUPzLxfZf3H4Z6D9b+PIn/nzapQAPECihrxHZswayinYV3nbcZKj2Qh3Aw2jzaP0OpYM6HFdZmosA,iv:nE4utRtwNCDEWOKjTaobpXJHWkXY6LQE1EvHGH+tUH0=,tag:+3UC7N8B1WCCPzBzmB4yog==,type:str]
CENTRALIS_TOKENS: ENC[AES256_GCM,data:nlDM6Y4rUuAiITo1iN/khDFiwCH8UlVA42SaB1d1a4CG7uVMZnl369MVTMWEXhe4GFOtXlSYgHt7XUMkVmCGQzfBwXed6VZcMPMD9XVkbdYz8+FtrOsf0KVQWvNOHQ6MnvOGMb/CVFBeo0A=,iv:OAw7hXOgAlUQWViCb/fkfqXaNEcyu7947ttbaqHYceM=,tag:sUoVL7Fac9c4dil/+bz7fw==,type:str] CENTRALIS_TOKENS: ENC[AES256_GCM,data:nlDM6Y4rUuAiITo1iN/khDFiwCH8UlVA42SaB1d1a4CG7uVMZnl369MVTMWEXhe4GFOtXlSYgHt7XUMkVmCGQzfBwXed6VZcMPMD9XVkbdYz8+FtrOsf0KVQWvNOHQ6MnvOGMb/CVFBeo0A=,iv:OAw7hXOgAlUQWViCb/fkfqXaNEcyu7947ttbaqHYceM=,tag:sUoVL7Fac9c4dil/+bz7fw==,type:str]
CENTRALIS_GOOGLE_CLIENT_ID: ENC[AES256_GCM,data:eTaMLUn/5KyQ8SXpLSZ7VwdoAakRtB9r0ZXcVKjfWywm7KrWee9W7jNu7yPb2MUbitrIQTl/t5qRtru1n9rygpDXXOFvtxSW,iv:ep3BEkwB/izgkHugGow0JfOPvdkPYtotd+/iayFCoxQ=,tag:odbGzuczZbMSU0KJk8jMdw==,type:str]
CENTRALIS_GOOGLE_CLIENT_SECRET: ENC[AES256_GCM,data:WEOJJD1dR+ygv2OUdaTue1tqVhHWRY/NJaUKNuaG/CHIifk=,iv:39gL9wUnNGf6rirIE0PJj9Uf7p7TWfLbXHiyeYPqfr4=,tag:B0g0GzhE6KNCfF4okanvlg==,type:str]
CENTRALIS_GOOGLE_HOSTED_DOMAIN: ENC[AES256_GCM,data:WmvQFhCvtgealw==,iv:KMjjdJ1H4hrTWnxvRe16+2Jq35tzQI8N1GsuG4kFY7w=,tag:Afwrmv1btCxK3vOWsnm6iQ==,type:str]
CENTRALIS_TEAM: ENC[AES256_GCM,data:0lj+Bv9m/cOaEpxNrFDeEmgLNRAaPlfQYDJ7o2wy80lv82iQrOjha9ZBvOC4f65WwzBKlOhcwum7CNRuPlHJ23AE8BXvaYxqudh74MUK7wYH34Yi9o9BENifngKM9h/vmDPlMA8kCgibp6URjRqRGK0rRhSKUA==,iv:9BRBrNZ6Uua+EquhUTQBMJWfPUNsraMNxKWB2MgaYdM=,tag:ePbUmmgOLpptHLKqF9HwzQ==,type:str]
sops: sops:
age: age:
- enc: | - enc: |
@ -18,7 +22,7 @@ sops:
JEUSbfXvUHvuMyY94DhbXmh2WgqxWYQuvF9UrDLeDw14tNPIGoIpFg== JEUSbfXvUHvuMyY94DhbXmh2WgqxWYQuvF9UrDLeDw14tNPIGoIpFg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2 recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
lastmodified: "2026-07-25T01:02:49Z" lastmodified: "2026-08-01T22:40:50Z"
mac: ENC[AES256_GCM,data:HsxO525FU/AfZw6Y5nTrfPIefl1InTV4M7e03hGsxZRHS2Fwsdqiyl/swI2oUFt3tfEyn0RwNB9cEeG/yo4UPPY80E5wnBvv0kaV0gfibBNwBRuIbPJZR/vXm6k8+60shcCOrtvpby4zUmtuk84kEK4mHWpVzh4xs5eYY37la6I=,iv:yR1Zq5TusAdzt7JnarXqRU3X896JMXJzrJw7p6tTzWs=,tag:lK9ph8OJyF/W1E4CLX8AGw==,type:str] mac: ENC[AES256_GCM,data:0ru6lJTTjI5aqGsPYrT3ku2jX0sL32W57LBzfmauf/Td32RXqSNE7cdNlLNwoWsdi/moiKzMexKFkWRBbDzTQprGcBOSbjg51g2n7czHEFnkehH1ixfxaunkVRO9chsude+IEndS9o8x5H+/HZA7NVFFEf2Era0DSeY9IIi0i/U=,iv:mmtmQp5DBAt2aahW5bIhKVY59AYeu60hVfrT7Dfe61c=,tag:FkvAiGbbrWdUTa5fJ8v59A==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.2 version: 3.13.2

View file

@ -106,7 +106,7 @@ export STACK_ENV_FILE
# --- image tags ----------------------------------------------------------------- # --- image tags -----------------------------------------------------------------
# Same persisted-tags contract as deploy.sh: incoming env wins, the file # Same persisted-tags contract as deploy.sh: incoming env wins, the file
# supplies the sibling. Stack mode keeps its own file so test/real never mix. # supplies the sibling. Stack mode keeps its own file so test/real never mix.
REGISTRY_HOST="${REGISTRY_HOST:-dev.jinemi.com}" REGISTRY_HOST="${REGISTRY_HOST:-git.thermograph.org}"
export REGISTRY_HOST export REGISTRY_HOST
TAGS_FILE="$APP_DIR/infra/deploy/.stack-image-tags.env" TAGS_FILE="$APP_DIR/infra/deploy/.stack-image-tags.env"
_incoming_backend="${BACKEND_IMAGE_TAG:-}" _incoming_backend="${BACKEND_IMAGE_TAG:-}"

View file

@ -62,7 +62,7 @@
services: services:
beta-web: beta-web:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
environment: environment:
# Beta's OWN role and OWN database on the shared instance. `db` resolves # Beta's OWN role and OWN database on the shared instance. `db` resolves
@ -108,7 +108,7 @@ services:
failure_action: rollback failure_action: rollback
beta-worker: beta-worker:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
environment: environment:
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta
@ -144,7 +144,7 @@ services:
condition: on-failure condition: on-failure
beta-lake: beta-lake:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
environment: environment:
THERMOGRAPH_ROLE: lake THERMOGRAPH_ROLE: lake
@ -176,7 +176,7 @@ services:
failure_action: rollback failure_action: rollback
beta-daemon: beta-daemon:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
# Same reasoning as prod's daemon: NOT env-entrypoint.sh, because that shim # Same reasoning as prod's daemon: NOT env-entrypoint.sh, because that shim
# execs the image's own entrypoint (Alembic + uvicorn) and migrations belong # execs the image's own entrypoint (Alembic + uvicorn) and migrations belong
# to the one-shot task. Source the host-rendered env and exec the binary. # to the one-shot task. Source the host-rendered env and exec the binary.
@ -218,7 +218,7 @@ services:
failure_action: rollback failure_action: rollback
beta-frontend: beta-frontend:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
# REQUIRED: overriding `entrypoint:` with no `command:` drops the image's # REQUIRED: overriding `entrypoint:` with no `command:` drops the image's
# CMD entirely, and env-entrypoint.sh's fallback (`exec uvicorn app:app`) # CMD entirely, and env-entrypoint.sh's fallback (`exec uvicorn app:app`)

View file

@ -64,7 +64,7 @@ services:
condition: on-failure condition: on-failure
web: web:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
environment: environment:
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph
@ -109,7 +109,7 @@ services:
failure_action: rollback failure_action: rollback
worker: worker:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
environment: environment:
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph
@ -153,7 +153,7 @@ services:
# healthy and answers 503/404, and web falls through to NASA — the lake is # healthy and answers 503/404, and web falls through to NASA — the lake is
# an accelerator, never a point of failure. # an accelerator, never a point of failure.
lake: lake:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
environment: environment:
THERMOGRAPH_ROLE: lake THERMOGRAPH_ROLE: lake
@ -186,7 +186,7 @@ services:
# (/usr/local/bin/thermograph-daemon, built into the backend image) and the # (/usr/local/bin/thermograph-daemon, built into the backend image) and the
# app share the /internal/* API contract, so one BACKEND_IMAGE_TAG rolls # app share the /internal/* API contract, so one BACKEND_IMAGE_TAG rolls
# them together and they can never skew. # them together and they can never skew.
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
# Not env-entrypoint.sh: that shim's handoff execs the image's # Not env-entrypoint.sh: that shim's handoff execs the image's
# deploy/entrypoint.sh (Alembic + uvicorn) whenever it exists, which is # deploy/entrypoint.sh (Alembic + uvicorn) whenever it exists, which is
# exactly what this service must never run — migrations belong to the # exactly what this service must never run — migrations belong to the
@ -243,7 +243,7 @@ services:
failure_action: rollback failure_action: rollback
frontend: frontend:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required} image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"] entrypoint: ["/host/env-entrypoint.sh"]
# REQUIRED, not cosmetic: overriding `entrypoint:` with no `command:` drops # REQUIRED, not cosmetic: overriding `entrypoint:` with no `command:` drops
# the image's own CMD entirely (Docker/Swarm semantics, not merged) -- # the image's own CMD entirely (Docker/Swarm semantics, not merged) --

View file

@ -36,7 +36,7 @@ if ! ufw status | grep -q "^Status: active"; then
echo "ports above. Before enabling, explicitly allow every port this node" echo "ports above. Before enabling, explicitly allow every port this node"
echo "already serves publicly (SSH at minimum; on vps2 specifically, also" echo "already serves publicly (SSH at minimum; on vps2 specifically, also"
echo "80/tcp and 443/tcp for the live thermograph.org/beta.thermograph.org" echo "80/tcp and 443/tcp for the live thermograph.org/beta.thermograph.org"
echo "Caddy; on vps1, 80/tcp and 443/tcp for dev.jinemi.com," echo "Caddy; on vps1, 80/tcp and 443/tcp for git.thermograph.org,"
echo "dashboard.thermograph.org and emigriffith.dev) — check 'ss -tlnp' for" echo "dashboard.thermograph.org and emigriffith.dev) — check 'ss -tlnp' for"
echo "what's actually listening first. Enabling ufw without doing this WILL" echo "what's actually listening first. Enabling ufw without doing this WILL"
echo "drop live traffic the moment it activates." echo "drop live traffic the moment it activates."

View file

@ -16,7 +16,7 @@
PORT=8137 PORT=8137
# --- Registry pull (deploy.sh / Terraform) --------------------------------------- # --- Registry pull (deploy.sh / Terraform) ---------------------------------------
# deploy.sh logs in to dev.jinemi.com's registry and `docker compose pull`s # deploy.sh logs in to git.thermograph.org's registry and `docker compose pull`s
# the image build-push.yml already pushed for the deployed commit (repo-split # the image build-push.yml already pushed for the deployed commit (repo-split
# Stage 6), instead of building in place. A personal access token with at least # Stage 6), instead of building in place. A personal access token with at least
# read:package scope -- the same REGISTRY_TOKEN secret build-push.yml uses (that # read:package scope -- the same REGISTRY_TOKEN secret build-push.yml uses (that
@ -243,11 +243,3 @@ THERMOGRAPH_BASE_URL=https://thermograph.org
# required — Discord delivers content for mentions/DMs. Unset/0 => no gateway # required — Discord delivers content for mentions/DMs. Unset/0 => no gateway
# connection. (Code: thermograph-backend notifications/discord_bot.py.) # connection. (Code: thermograph-backend notifications/discord_bot.py.)
#THERMOGRAPH_DISCORD_BOT=1 #THERMOGRAPH_DISCORD_BOT=1
# Whether that gateway bot answers @mentions in SERVER CHANNELS. Default on
# (unset = on); set 0/false only once the operator's conversational agent is
# live, because it replies as this same bot account and both answering means one
# mention gets two replies. DMs are answered here regardless, and the /grade
# slash command is a separate HTTP path unaffected by this — which is what keeps
# grading available while the desktop running that agent is asleep.
# (Agent code: the discord-voice-bot repo, AGENTS.md.)
#THERMOGRAPH_DISCORD_BOT_MENTIONS=0

View file

@ -102,7 +102,7 @@ services:
# builds `jinemi/thermograph/backend:local` from the backend repo (see # builds `jinemi/thermograph/backend:local` from the backend repo (see
# infra Makefile) and this pulls/uses it. BACKEND_IMAGE_PATH/TAG are # infra Makefile) and this pulls/uses it. BACKEND_IMAGE_PATH/TAG are
# independent of the frontend's, so the two services deploy separately. # independent of the frontend's, so the two services deploy separately.
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -180,7 +180,7 @@ services:
# without them the service stays healthy and every read falls through to # without them the service stays healthy and every read falls through to
# NASA. Never published on a host port: only the backend talks to it. # NASA. Never published on a host port: only the backend talks to it.
lake: lake:
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
environment: environment:
THERMOGRAPH_ROLE: lake THERMOGRAPH_ROLE: lake
PORT: 8141 PORT: 8141
@ -207,7 +207,7 @@ services:
# share the /internal/* API contract, so they must roll together — a # share the /internal/* API contract, so they must roll together — a
# separate tag could skew them. It owns the Discord gateway websocket and # separate tag could skew them. It owns the Discord gateway websocket and
# the recurring-job timers; anything needing data calls back into backend. # the recurring-job timers; anything needing data calls back into backend.
image: ${REGISTRY_HOST:-dev.jinemi.com}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local} image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
# Bypass deploy/entrypoint.sh entirely: that script runs Alembic, and the # Bypass deploy/entrypoint.sh entirely: that script runs Alembic, and the
# backend service's boot already owns migrations — two containers racing # backend service's boot already owns migrations — two containers racing
# `alembic upgrade head` against one database is a real hazard, not a # `alembic upgrade head` against one database is a real hazard, not a
@ -262,7 +262,7 @@ services:
# frontend/Dockerfile (which starts the thermograph-frontend Go binary # frontend/Dockerfile (which starts the thermograph-frontend Go binary
# directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent # directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent
# FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag. # FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag.
image: ${REGISTRY_HOST:-dev.jinemi.com}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:-local} image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:-local}
# Its own register() fetches the IndexNow key from backend at boot -- must # Its own register() fetches the IndexNow key from backend at boot -- must
# wait for a real, healthy backend, not just a started container. # wait for a real, healthy backend, not just a started container.
depends_on: depends_on:

View file

@ -33,7 +33,7 @@ would silently overwrite the first's site instead of adding to it (there is no
merge). Prod claims that slot on vps2 today, so beta's public reverse-proxy (if merge). Prod claims that slot on vps2 today, so beta's public reverse-proxy (if
and when `beta.thermograph.org` is exposed) has to be a site block in vps2's and when `beta.thermograph.org` is exposed) has to be a site block in vps2's
shared, hand-maintained Caddy config instead — the same pattern shared, hand-maintained Caddy config instead — the same pattern
`deploy/forgejo/docker-stack.yml` already uses to put `dev.jinemi.com` in `deploy/forgejo/docker-stack.yml` already uses to put `git.thermograph.org` in
front of Forgejo's loopback port on vps1, alongside that box's own Caddy-fronted front of Forgejo's loopback port on vps1, alongside that box's own Caddy-fronted
`emigriffith.dev`. `emigriffith.dev`.
@ -180,7 +180,7 @@ Terraform brings the stack up — don't let Terraform recreate containers mid-mi
`/etc/caddy/Caddyfile` prod's apply just installed (see the table above). Reach `/etc/caddy/Caddyfile` prod's apply just installed (see the table above). Reach
beta via an SSH tunnel, or front it with a site block in vps2's own beta via an SSH tunnel, or front it with a site block in vps2's own
hand-maintained Caddy config (outside Terraform), the same way Forgejo's hand-maintained Caddy config (outside Terraform), the same way Forgejo's
`dev.jinemi.com` reaches Forgejo's loopback port on vps1. `git.thermograph.org` reaches Forgejo's loopback port on vps1.
`COOKIE_SECURE` is auto-set to `0` when there's no domain (a Secure `COOKIE_SECURE` is auto-set to `0` when there's no domain (a Secure
cookie is never sent over plain HTTP) and `1` behind Caddy TLS. cookie is never sent over plain HTTP) and `1` behind Caddy TLS.
- The rendered Caddyfile only reverse-proxies the app. The repo's - The rendered Caddyfile only reverse-proxies the app. The repo's

View file

@ -256,7 +256,7 @@ resource "null_resource" "host" {
. deploy/render-secrets.sh . deploy/render-secrets.sh
render_thermograph_secrets ${var.app_dir} render_thermograph_secrets ${var.app_dir}
set -a; . /etc/thermograph-topology.env; . /etc/thermograph.env; set +a set -a; . /etc/thermograph-topology.env; . /etc/thermograph.env; set +a
export REGISTRY_HOST="dev.jinemi.com" BACKEND_IMAGE_TAG="${var.backend_image_tag}" FRONTEND_IMAGE_TAG="${var.frontend_image_tag}" export REGISTRY_HOST="git.thermograph.org" BACKEND_IMAGE_TAG="${var.backend_image_tag}" FRONTEND_IMAGE_TAG="${var.frontend_image_tag}"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username admin_emi --password-stdin echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username admin_emi --password-stdin
docker compose ${local.compose_flags} pull backend frontend docker compose ${local.compose_flags} pull backend frontend
docker compose ${local.compose_flags} up -d --remove-orphans docker compose ${local.compose_flags} up -d --remove-orphans

View file

@ -87,7 +87,7 @@ hosts = {
# ONE environment on a box can own that file. Prod already claims it on # ONE environment on a box can own that file. Prod already claims it on
# this host, so beta.thermograph.org's public reverse-proxy has to be a # this host, so beta.thermograph.org's public reverse-proxy has to be a
# site block in vps2's shared, hand-maintained Caddy instead (the same # site block in vps2's shared, hand-maintained Caddy instead (the same
# pattern deploy/forgejo/docker-stack.yml uses for dev.jinemi.com # pattern deploy/forgejo/docker-stack.yml uses for git.thermograph.org
# on vps1) -- not something this module can render for a second # on vps1) -- not something this module can render for a second
# environment on the same host. # environment on the same host.
domain = "" domain = ""
@ -151,13 +151,13 @@ hosts = {
# } # }
# Optional overrides (shown with their defaults): # Optional overrides (shown with their defaults):
# repo_url = "https://dev.jinemi.com/admin_emi/thermograph-infra.git" # repo_url = "https://git.thermograph.org/admin_emi/thermograph-infra.git"
# app_port = 8137 # app_port = 8137
# frontend_port = 8080 # frontend_port = 8080
# #
# thermograph-infra is a PRIVATE repo, so a host cloning it for the first time # thermograph-infra is a PRIVATE repo, so a host cloning it for the first time
# needs read credentials embedded in repo_url, e.g. a Forgejo deploy token: # needs read credentials embedded in repo_url, e.g. a Forgejo deploy token:
# repo_url = "https://deploy:REPLACE_WITH_TOKEN@dev.jinemi.com/admin_emi/thermograph-infra.git" # repo_url = "https://deploy:REPLACE_WITH_TOKEN@git.thermograph.org/admin_emi/thermograph-infra.git"
# --------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------
# Self-hosted Open-Meteo archive (only used by environments with openmeteo = true) # Self-hosted Open-Meteo archive (only used by environments with openmeteo = true)

View file

@ -127,9 +127,9 @@ variable "gcp_hosts" {
} }
variable "repo_url" { variable "repo_url" {
description = "Git remote to clone from when a host has no checkout yet. Points at THIS repo (thermograph-infra) now, not the app repo -- the app's own source is never checked out on a host; only its published registry images are pulled (see var.hosts[*].backend_image_tag / frontend_image_tag). thermograph-infra is a private repo, so this typically needs embedded read credentials, e.g. a Forgejo deploy token: \"https://<token-name>:<token>@dev.jinemi.com/admin_emi/thermograph-infra.git\"." description = "Git remote to clone from when a host has no checkout yet. Points at THIS repo (thermograph-infra) now, not the app repo -- the app's own source is never checked out on a host; only its published registry images are pulled (see var.hosts[*].backend_image_tag / frontend_image_tag). thermograph-infra is a private repo, so this typically needs embedded read credentials, e.g. a Forgejo deploy token: \"https://<token-name>:<token>@git.thermograph.org/admin_emi/thermograph-infra.git\"."
type = string type = string
default = "https://dev.jinemi.com/admin_emi/thermograph-infra.git" default = "https://git.thermograph.org/admin_emi/thermograph-infra.git"
sensitive = true sensitive = true
} }

View file

@ -94,7 +94,7 @@ Both the dashboard and Forgejo log in with Google. Create **one** Google Cloud
OAuth 2.0 Client (type: *Web application*) and register both redirect URIs: OAuth 2.0 Client (type: *Web application*) and register both redirect URIs:
- Grafana: `https://dashboard.thermograph.org/login/google` - Grafana: `https://dashboard.thermograph.org/login/google`
- Forgejo: `https://dev.jinemi.com/user/oauth2/google/callback` - Forgejo: `https://git.thermograph.org/user/oauth2/google/callback`
**Grafana:** put the client id/secret in `.env`, set `OAUTH_ENABLED=true`, and **Grafana:** put the client id/secret in `.env`, set `OAUTH_ENABLED=true`, and
`docker compose up -d`. Login is locked to pre-provisioned accounts `docker compose up -d`. Login is locked to pre-provisioned accounts