Compare commits
1 commit
dev
...
feat/centr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a981729417 |
64 changed files with 211 additions and 2095 deletions
|
|
@ -71,30 +71,20 @@ correct" rather than erroring.
|
|||
is a divergence to reconcile deliberately, not to overwrite.
|
||||
2. **`dev` is the default branch.**
|
||||
`forge_repo_settings(apply_flow_defaults=true)` — sets the default branch and
|
||||
permits the merge styles the chain uses, plus 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.
|
||||
permits every merge style the chain uses, including `fast-forward-only`, plus
|
||||
rebase-updates (without which the merge queue's core primitive 403s).
|
||||
3. **Protection.** `forge_branch_protection(branch="dev", apply=true)`, then
|
||||
`main`, then `release`.
|
||||
|
||||
**All three** branches get the required check. `release` was the exception
|
||||
until 2026-08-01: `pr-build.yml` fired only for PRs into `dev` and `main`, so
|
||||
a PR into `release` reported no status at all, and requiring a context
|
||||
nothing produces makes every production promotion permanently unmergeable —
|
||||
`dev` and `main` get the required check; **`release` does not**, and that is
|
||||
deliberate. `pr-build.yml` fires only for PRs into `dev` and `main`, so a PR
|
||||
into `release` reports no status at all — requiring a context nothing
|
||||
produces would make every production promotion permanently unmergeable, with
|
||||
a 405 that names no cause. This estate has already lost an afternoon to
|
||||
exactly that, from a single mistyped check name.
|
||||
|
||||
It was closed in the only safe order, and that order still governs any change
|
||||
to the context name, or a new protected branch: add the branch to
|
||||
`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.
|
||||
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
|
||||
`on.pull_request.branches`, confirm on a real PR that the context appears,
|
||||
*then* pass `require_checks_on_release: true`. The other order blocks prod.
|
||||
4. **Stale branches.** `forge_branches()` lists every branch with its age, how
|
||||
much unmerged work it carries, and a verdict:
|
||||
- `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**
|
||||
head. The run you saw before the update was for a merge base that no longer
|
||||
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.
|
||||
|
||||
`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,
|
||||
not mid-feature. `promote(from="dev", to="main")` opens the PR with the
|
||||
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
|
||||
around it with cherry-picks.**
|
||||
|
|
@ -161,23 +151,17 @@ SHAs, and merging it would fire the target's deploys for nothing.
|
|||
|
||||
### Promotion: `main` → `release`
|
||||
|
||||
Prepare with `promote`, then ask. On the owner's yes, merge with
|
||||
`method="fast-forward-only"` and tag it: `forge_releases(create={tag:"v<semver>", target:"release", ...})`.
|
||||
Prepare with `promote`, then ask. On the owner's yes, merge with `method="merge"`
|
||||
and tag it: `forge_releases(create={tag:"v<semver>", target:"release", ...})`.
|
||||
|
||||
**On fast-forward.** Both promotions move by fast-forward, and as of
|
||||
2026-08-01 that is in force rather than aspirational. It was blocked until then
|
||||
for a structural reason worth remembering: every promotion had been a merge
|
||||
commit *into* the target, which the source never receives, so the branches were
|
||||
mutually divergent by construction and Forgejo refused `fast-forward-only` —
|
||||
correctly. The one-time reconciliation the old policy was waiting for has
|
||||
happened; the trees were already identical, so it carried no content.
|
||||
|
||||
`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.
|
||||
**On fast-forward.** The written policy is that `release` moves only by
|
||||
fast-forward. It cannot today: every promotion so far has been a merge commit
|
||||
*into* the target, which the source never receives, so the branches are mutually
|
||||
divergent by construction and Forgejo will refuse a `fast-forward-only` merge —
|
||||
correctly. The style is implemented and accepted by `forge_pr_merge`; adopting
|
||||
it needs a one-time reconciliation of the protected branches, which is the
|
||||
owner's decision. Until that happens, promotions are merge commits. Do not
|
||||
attempt to "fix" this with a rebase or a force-push.
|
||||
|
||||
### Hotfix
|
||||
|
||||
|
|
|
|||
|
|
@ -16,14 +16,9 @@ name: Build + push images (Forgejo registry)
|
|||
# docker.sock automounted (Docker-outside-of-Docker, no privileged mode); the
|
||||
# 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
|
||||
# to vps1's public IP, whose Caddy ACL rejects /v2/ paths, so any runner host
|
||||
# that pushes or pulls needs an /etc/hosts entry pinning it to 10.10.0.2.
|
||||
#
|
||||
# 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".
|
||||
# The registry is deliberately mesh-only: git.thermograph.org's public DNS
|
||||
# resolves to beta's public IP, whose Caddy ACL rejects /v2/ paths, so any
|
||||
# runner host that pushes or pulls needs an /etc/hosts entry.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -172,26 +167,6 @@ jobs:
|
|||
fi
|
||||
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)
|
||||
if: steps.image.outputs.changed == 'true'
|
||||
run: docker push "${{ steps.image.outputs.sha_tag }}"
|
||||
|
|
|
|||
|
|
@ -19,11 +19,7 @@ name: PR build (required check)
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
# `release` included: it is the branch that deploys to PROD, and it used to
|
||||
# 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]
|
||||
branches: [dev, main]
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event.pull_request.number }}
|
||||
|
|
@ -77,32 +73,18 @@ jobs:
|
|||
if: needs.changes.outputs.observability == 'true'
|
||||
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:
|
||||
name: gate
|
||||
# always(): skipped domain jobs (nothing changed there) must not skip the
|
||||
# gate itself -- the required check has to report on EVERY PR.
|
||||
if: always()
|
||||
needs: [changes, build-backend, build-frontend, validate-observability,
|
||||
lint-shell, guard-secrets]
|
||||
needs: [changes, build-backend, build-frontend, validate-observability]
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Reduce domain results
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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 \
|
||||
"backend=${{ needs.build-backend.result }}" \
|
||||
"frontend=${{ needs.build-frontend.result }}" \
|
||||
|
|
@ -113,17 +95,4 @@ jobs:
|
|||
*) ok=0 ;;
|
||||
esac
|
||||
done
|
||||
# Repo-wide invariants: `skipped` is a FAILURE. These have no `if:` and
|
||||
# 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; }
|
||||
[ "$ok" = 1 ] || { echo "a changed domain's check failed"; exit 1; }
|
||||
|
|
|
|||
|
|
@ -11,11 +11,6 @@ name: secrets-guard
|
|||
# runs when you expect it to isn't a backstop.
|
||||
|
||||
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:
|
||||
push:
|
||||
branches: [dev, main, release]
|
||||
|
|
|
|||
|
|
@ -20,11 +20,6 @@ name: shell-lint
|
|||
# to fix any new findings in the same PR as the bump.
|
||||
|
||||
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:
|
||||
push:
|
||||
branches: [dev, main, release]
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -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
|
||||
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
|
||||
serialised merge queue, promotions, hotfix down-merges — and
|
||||
`.claude/ownership.md` is the module map saying which tasks may run
|
||||
|
|
|
|||
|
|
@ -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
|
||||
archived monorepo) and push `main`.
|
||||
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,
|
||||
auto-merge on green.
|
||||
4. Hosts (beta, then prod): clone the monorepo to `/opt/thermograph.new`;
|
||||
|
|
|
|||
|
|
@ -15,17 +15,6 @@ WORKDIR /src
|
|||
COPY daemon/go.mod daemon/go.sum ./
|
||||
RUN go mod download
|
||||
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
|
||||
# binary), which is what lets the final stage's COPY layer cache-hit when only
|
||||
# Python code changed.
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
@ -47,23 +47,6 @@ type Config struct {
|
|||
// DiscordToken is the bot token; only meaningful when DiscordEnabled.
|
||||
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
|
||||
IndexNowInterval time.Duration
|
||||
}
|
||||
|
|
@ -78,17 +61,6 @@ func truthy(s string) bool {
|
|||
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
|
||||
// falls back to the default rather than erroring — mirrors the Python
|
||||
// `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
|
||||
// disables it — the cron half is still worth running on its own.
|
||||
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.IndexNowInterval = intervalHours(getenv("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS"), DefaultIndexNowIntervalHours)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ import (
|
|||
// cfg.DiscordEnabled — an unconfigured deploy never gets this far, so an
|
||||
// unconfigured deploy pays nothing.
|
||||
func Run(ctx context.Context, cfg *config.Config, api Grader) error {
|
||||
b := New(cfg.DiscordToken, api)
|
||||
b.guildMentions = cfg.DiscordGuildMentions
|
||||
return b.Run(ctx)
|
||||
return New(cfg.DiscordToken, api).Run(ctx)
|
||||
}
|
||||
|
||||
// v10 JSON gateway. On a resume we reconnect to the session's
|
||||
|
|
@ -146,10 +144,6 @@ type Bot struct {
|
|||
sem chan struct{} // bounds concurrent MESSAGE_CREATE handlers
|
||||
rest string // Discord REST base; swapped for a test server in tests
|
||||
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
|
||||
|
|
@ -161,7 +155,6 @@ func New(token string, api Grader) *Bot {
|
|||
sem: make(chan struct{}, maxConcurrentReplies),
|
||||
rest: discordAPIBase,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
guildMentions: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,12 +97,7 @@ func stripMentions(content, botID string) string {
|
|||
// 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
|
||||
// but not collapsed, matching the Python's .strip()).
|
||||
//
|
||||
// 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) {
|
||||
func triage(m *gwMessage, botID string) (action, string) {
|
||||
if m.Author.Bot || string(m.Author.ID) == botID {
|
||||
return actSilent, ""
|
||||
}
|
||||
|
|
@ -110,9 +105,6 @@ func triage(m *gwMessage, botID string, guildMentions bool) (action, string) {
|
|||
if !dm && !mentionsBot(m, botID) {
|
||||
return actSilent, ""
|
||||
}
|
||||
if !dm && !guildMentions {
|
||||
return actSilent, ""
|
||||
}
|
||||
var query string
|
||||
if dm {
|
||||
query = strings.TrimSpace(m.Content)
|
||||
|
|
|
|||
|
|
@ -52,21 +52,21 @@ func asDM() func(*gwMessage) {
|
|||
// --- when the bot stays silent ----------------------------------------------
|
||||
|
||||
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 {
|
||||
t.Fatalf("bot author should be ignored, got action %d", act)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("own message should be ignored, got action %d", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoresGuildMessageWithoutAMention(t *testing.T) {
|
||||
act, _ := triage(testMsg("just chatting"), botID, true)
|
||||
act, _ := triage(testMsg("just chatting"), botID)
|
||||
if act != actSilent {
|
||||
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 ----------------------------------------------------
|
||||
|
||||
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" {
|
||||
t.Fatalf("want grade %q, got action %d query %q", "Phoenix", act, query)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
t.Fatalf("want grade %q, got action %d query %q", "Tokyo", act, query)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
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) {
|
||||
// Parity with the Python DM path (.strip(), not a collapse): only the
|
||||
// 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" {
|
||||
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) {
|
||||
act, query := triage(testMsg("<@999>", mentioning(botID)), botID, true)
|
||||
act, query := triage(testMsg("<@999>", mentioning(botID)), botID)
|
||||
if act != actHelp || 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" {
|
||||
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" {
|
||||
t.Fatalf("want grade %q, got action %d query %q", "Lima", act, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// never stall heartbeats or the read loop.
|
||||
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 {
|
||||
case actSilent:
|
||||
case actHelp:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -68,15 +68,6 @@ CREATE TABLE IF NOT EXISTS revgeo (
|
|||
label TEXT, -- NULL == lookup ran but found no label
|
||||
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
|
||||
|
|
@ -102,24 +93,6 @@ _PG_SCHEMA = (
|
|||
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
|
||||
|
|
@ -140,23 +113,11 @@ if IS_POSTGRES:
|
|||
"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"
|
||||
)
|
||||
_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:
|
||||
_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_GET_REVGEO = "SELECT label, updated_at FROM revgeo WHERE key=?"
|
||||
_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
|
||||
# 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
|
||||
|
||||
|
||||
# --- 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:
|
||||
"""Row counts + on-disk size, for the migrate script's summary output."""
|
||||
out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0,
|
||||
"narrative": 0, "bytes": 0}
|
||||
out = {"db_path": os.path.abspath(DB_PATH), "derived": 0, "revgeo": 0, "bytes": 0}
|
||||
try:
|
||||
with _conn() as conn:
|
||||
if conn is None:
|
||||
return out
|
||||
out["derived"] = conn.execute("SELECT COUNT(*) FROM derived").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.
|
||||
out["bytes"] = sum(os.path.getsize(p) for p in (DB_PATH, DB_PATH + "-wal")
|
||||
if os.path.exists(p))
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ services:
|
|||
# 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
|
||||
# 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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -31,11 +31,10 @@ import threading
|
|||
import time
|
||||
|
||||
import polars as pl
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.internal_routes import _require_internal_token
|
||||
from data import era5lake
|
||||
|
||||
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"|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():
|
||||
"""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 iceberg") # as the runtime user (see Dockerfile)
|
||||
host = cfg["endpoint"].split("://", 1)[-1]
|
||||
# A SECRET, not `SET s3_secret_access_key`: settings are readable back out
|
||||
# with current_setting(), so the old form let any caller of /query retrieve
|
||||
# the bucket credentials. Secret values are opaque -- duckdb_secrets() lists
|
||||
# the name and scope but never the key material.
|
||||
con.execute(
|
||||
"CREATE OR REPLACE SECRET lake (TYPE S3, KEY_ID ?, SECRET ?, "
|
||||
"REGION ?, ENDPOINT ?, URL_STYLE 'path')",
|
||||
[cfg["access_key"], cfg["secret_key"], cfg["region"], host])
|
||||
con.execute(f"SET s3_endpoint='{host}'")
|
||||
con.execute("SET s3_url_style='path'")
|
||||
con.execute(f"SET s3_region='{cfg['region']}'")
|
||||
con.execute("SET s3_access_key_id=?", [cfg["access_key"]])
|
||||
con.execute("SET s3_secret_access_key=?", [cfg["secret_key"]])
|
||||
# Newest metadata JSON wins (one single-page LIST of metadata/ — cheap);
|
||||
# pyiceberg names them NNNNN-uuid.metadata.json, so lexicographic max is
|
||||
# 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 manifest AS SELECT * FROM
|
||||
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
|
||||
|
||||
|
||||
@app.post("/query", dependencies=[Depends(_require_internal_token)])
|
||||
@app.post("/query")
|
||||
def query(q: Query):
|
||||
sql = q.sql.strip().rstrip(";").strip()
|
||||
if not sql.lower().startswith(("select", "with")) or ";" in sql \
|
||||
or _FORBIDDEN.search(sql):
|
||||
raise HTTPException(status_code=400,
|
||||
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()
|
||||
if con is None:
|
||||
raise HTTPException(status_code=503, detail="lake not configured")
|
||||
|
|
|
|||
|
|
@ -15,11 +15,8 @@ Register the command definitions with scripts/register_discord_commands.py.
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from nacl.exceptions import BadSignatureError
|
||||
from nacl.signing import VerifyKey
|
||||
|
|
@ -56,109 +53,20 @@ def verify(signature: str, timestamp: str, body: bytes, public_key: str | None =
|
|||
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:
|
||||
"""Best curated-city match for whatever someone actually typed.
|
||||
|
||||
The old version matched the query against `name` alone, exact-or-prefix.
|
||||
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)
|
||||
"""Best curated-city match for a free-text name: exact name first, then a
|
||||
prefix match, preferring the largest (cities.json is population-sorted)."""
|
||||
q = (query or "").strip().casefold()
|
||||
if not q:
|
||||
return None
|
||||
|
||||
# "Vilnius, Lithuania" -> head "vilnius", tail "lithuania". Only the first
|
||||
# comma splits: "Washington, DC, USA" keeps "dc, usa" as the qualifier, and
|
||||
# the ISO/admin1 check below still matches it on "dc".
|
||||
head, _, tail = q.partition(",")
|
||||
head, tail = head.strip(), tail.strip()
|
||||
|
||||
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):
|
||||
exact, prefix = None, None
|
||||
for c in cities.all_cities(): # already sorted by population, desc
|
||||
name = c["name"].casefold()
|
||||
if name == q:
|
||||
exact = exact or c
|
||||
elif prefix is None and name.startswith(q):
|
||||
prefix = c
|
||||
if named:
|
||||
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
|
||||
return exact or prefix
|
||||
|
||||
|
||||
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."""
|
||||
city = _resolve_city(query)
|
||||
if city is None:
|
||||
# Don't read a whole sentence back as if it were a city name — quoting
|
||||
# "Tell me about weather in lithuania" as a place we don't track is what
|
||||
# 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}
|
||||
return {"content": f"I don't track a city called '{query}' yet. "
|
||||
"Try a major city name.", "flags": _EPHEMERAL}
|
||||
card = homepage._grade_city(city, datetime.date.today())
|
||||
if card is None:
|
||||
return {"content": f"No recent reading is cached for {city['name']} yet. "
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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-
|
||||
# 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}"
|
||||
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}"
|
||||
COMPOSE=(docker compose -f docker-compose.test.yml)
|
||||
PORT_URL="http://127.0.0.1:${SMOKE_HOST_PORT}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -53,69 +53,6 @@ audit.ACTIVITY_DIR = os.path.join(_TMP, "logs", "activity")
|
|||
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:
|
||||
"""`d` shifted back `years` years, same month/day (Feb 29 → Feb 28)."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -100,65 +100,6 @@ def test_resolve_city_prefers_exact_then_population():
|
|||
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 -------------------------------------------------
|
||||
|
||||
def test_interactions_route_pings_and_rejects(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -7,20 +7,12 @@ import polars as pl
|
|||
import pytest
|
||||
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
|
||||
def lake(tmp_path, monkeypatch):
|
||||
"""A tiny two-point lake (points, hive partitions, manifest) on disk."""
|
||||
from data import era5lake
|
||||
|
||||
monkeypatch.setenv("THERMOGRAPH_INTERNAL_TOKEN", TOKEN)
|
||||
|
||||
points = [(154, 1439), (154, 0)] # around London
|
||||
d0 = datetime.date(1940, 1, 1)
|
||||
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):
|
||||
r = lake.post("/query", headers=HDR, json={"sql":
|
||||
r = lake.post("/query", json={"sql":
|
||||
"SELECT year, month, count(*) AS n FROM era5_daily "
|
||||
"WHERE year = 1940 AND month = 1 GROUP BY year, month"})
|
||||
assert r.status_code == 200
|
||||
|
|
@ -107,7 +99,7 @@ def test_query_selects_with_partition_predicates(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.json()["rows"][0][0] == 2
|
||||
|
||||
|
|
@ -119,115 +111,4 @@ def test_query_manifest_view(lake):
|
|||
"COPY era5_daily TO 'x'",
|
||||
])
|
||||
def test_query_rejects_non_select(lake, sql):
|
||||
assert lake.post("/query", headers=HDR,
|
||||
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
|
||||
assert lake.post("/query", json={"sql": sql}).status_code == 400
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
|||
from accounts import api_accounts
|
||||
from api import content_routes
|
||||
from api import internal_routes
|
||||
from api import narrative_routes
|
||||
from core import audit
|
||||
from data import climate
|
||||
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
|
||||
# backend/api/content_routes.py.
|
||||
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
|
||||
# bot + recurring jobs that used to run in _lifespan). Deliberately NOT under
|
||||
# BASE — the daemon hits THERMOGRAPH_API_BASE_INTERNAL directly, same posture
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ all on a WireGuard mesh (`10.10.0.0/24`):
|
|||
|
||||
| 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 |
|
||||
| **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 |
|
||||
|
|
@ -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:
|
||||
|
||||
- **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
|
||||
mesh. Any host that pulls images needs
|
||||
`10.10.0.2 dev.jinemi.com git.thermograph.org` in `/etc/hosts` — both names;
|
||||
`git.thermograph.org` is the old registry host, still served because
|
||||
pre-migration image tags carry it.
|
||||
mesh. Any host that pulls images needs `10.10.0.2 git.thermograph.org` in
|
||||
`/etc/hosts`.
|
||||
- **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`),
|
||||
and it lives alone on vps1. Most of the time you don't care which stack
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ make down
|
|||
`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
|
||||
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.
|
||||
`install` builds that tag locally from `backend/Dockerfile` and
|
||||
`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.
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ alembic + `/healthz` 200 + a real `/api/v2/place` 200.
|
|||
|
||||
Triggers on pushes to `dev`/`main`/`release` touching `backend/**` or
|
||||
`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
|
||||
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
|
||||
whole set, not whichever domain moved last.
|
||||
- The registry is mesh-only, so the runner host needs
|
||||
`10.10.0.2 dev.jinemi.com git.thermograph.org` in `/etc/hosts`.
|
||||
- The registry host itself comes from the Forgejo Actions variable
|
||||
`REGISTRY_HOST`, not from the workflow file.
|
||||
`10.10.0.2 git.thermograph.org` in `/etc/hosts`.
|
||||
|
||||
### `deploy.yml` — one file, three environments, two services
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ desktop:
|
|||
|
||||
| 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 |
|
||||
| **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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -227,12 +227,10 @@ It's executable documentation until state is bootstrapped.
|
|||
|
||||
### 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
|
||||
`10.10.0.2 dev.jinemi.com git.thermograph.org` in `/etc/hosts` — **both** names:
|
||||
the new one is the registry host and the bearer-token realm, the old one still
|
||||
prefixes every image tag pushed before the migration. vps1 itself doesn't need
|
||||
the pin — it *is* the box.
|
||||
`10.10.0.2 git.thermograph.org` in `/etc/hosts`. vps1 itself doesn't — it *is*
|
||||
the box.
|
||||
|
||||
### Forgejo runners only have the labels they registered with
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ pages:
|
|||
about:
|
||||
title: "About Thermograph: how the weather grades are calculated"
|
||||
description: >-
|
||||
How Thermograph works: ~45 years of ERA5 climate history, a ±7-day
|
||||
How Thermograph works: ~45 years of ERA5 climate history, a ±7-day
|
||||
seasonal window, and empirical percentiles that grade each day relative to
|
||||
its own location.
|
||||
privacy:
|
||||
|
|
@ -21,7 +21,7 @@ pages:
|
|||
temperatures by month, all-time records, and how today's weather compares
|
||||
to local history.
|
||||
glossary_index:
|
||||
title: "Weather & climate glossary: heat index, feels-like, percentile, and more"
|
||||
title: "Weather & climate glossary: heat index, feels-like, percentile, and more"
|
||||
description: >-
|
||||
Plain-language definitions of weather and climate terms: climate normal,
|
||||
percentile, temperature anomaly, feels-like, heat index, wind chill,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ services:
|
|||
# to the split-era v0.0.2-split-ci, under the retired
|
||||
# admin_emi/thermograph-backend/app path, long after both were superseded.)
|
||||
# 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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package contentdata
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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: "±7-day" in the
|
||||
// SERP snippet, "Weather & 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 (&), decimal (±) and hex (±) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ THERMOGRAPH_INTERNAL_TOKEN=
|
|||
# in its own app repo (thermograph-backend / thermograph-frontend) tagged
|
||||
# :local, which is the default here -- only set these to pull a specific
|
||||
# published build instead of building locally.
|
||||
# REGISTRY_HOST=dev.jinemi.com
|
||||
# REGISTRY_HOST=git.thermograph.org
|
||||
# BACKEND_IMAGE_PATH=admin_emi/thermograph-backend/app
|
||||
# BACKEND_IMAGE_TAG=local
|
||||
# FRONTEND_IMAGE_PATH=admin_emi/thermograph-frontend/app
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ rather than which environment happens to live there:
|
|||
|
||||
| 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 |
|
||||
| **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 |
|
||||
|------|------|-----------|-------|
|
||||
| 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) |
|
||||
|
||||
```
|
||||
|
|
@ -179,9 +179,8 @@ The GitHub → Forgejo cutover is complete; this records what was done:
|
|||
failed login attempt, not just config inspection)
|
||||
- [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] `https://dev.jinemi.com` serves Forgejo over TLS (and
|
||||
`https://git.thermograph.org` still does, off the same site block);
|
||||
`/v2/` registry API is 403 from off-mesh (firewalled to the WireGuard CIDR)
|
||||
- [x] `https://git.thermograph.org` serves Forgejo over TLS; `/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
|
||||
merge → deploy lands on the branch's mapped environment
|
||||
- [x] GitHub retired as git host + CI; kept only as a read-only history mirror
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ Two VPS boxes plus the operator's desktop, on one WireGuard mesh, named by
|
|||
ROLE rather than by environment:
|
||||
|
||||
- **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
|
||||
`ROOT_URL` *and* the registry host in every image name — while
|
||||
`git.thermograph.org` is still served off the same Caddy site block for
|
||||
pre-migration image tags and already-registered runners, so don't retire it;
|
||||
see `deploy/forgejo/README.md`), Grafana/Loki/Alloy
|
||||
(git + CI + registry; `dev.jinemi.com` is canonical — Forgejo's `ROOT_URL` —
|
||||
and `git.thermograph.org` is still served off the same Caddy site block and
|
||||
still load-bearing for the registry, mesh pins and runner URLs, so don't
|
||||
retire it; see `deploy/forgejo/README.md`), Grafana/Loki/Alloy
|
||||
(`dashboard.thermograph.org`), the `emigriffith.dev` portfolio site, and the
|
||||
**dev** environment (`/opt/thermograph-dev`, tracks `dev`), with its own
|
||||
Postgres container. Dev is mesh-only here — published on `10.10.0.2:8137`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
**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.
|
||||
|
||||
```
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
`/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
|
||||
way). Check `git.thermograph.org` the same way: it is still served, and a
|
||||
rollback to an image tag pushed before the domain migration dials it.
|
||||
with Forgejo on vps1 itself, since `git.thermograph.org` then resolves to
|
||||
itself either way).
|
||||
|
||||
## The dev environment (Docker Compose, on vps1)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
environment is passed as data (`THERMOGRAPH_ENV`), not baked into which secret
|
||||
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.)
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
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`
|
||||
to `/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
|
||||
**both** names: `dev.jinemi.com` is the registry host and the bearer-token
|
||||
realm, and `git.thermograph.org` still prefixes every image tag pushed before
|
||||
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
|
||||
vps2 failed with a 403 until adding `10.10.0.2 git.thermograph.org` to
|
||||
`/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). vps1
|
||||
itself doesn't need this — it's the box Forgejo runs on, so `git.thermograph.org`
|
||||
just resolves to itself either way. `build-push.yml`'s own header comment
|
||||
documents the same requirement for the CI runner host.
|
||||
|
||||
**Current production layout**: prod's Caddy config owns **`thermograph.org`**
|
||||
|
|
|
|||
|
|
@ -2,16 +2,11 @@
|
|||
#
|
||||
# vps1 serves:
|
||||
# emigriffith.dev -> static portfolio site (from disk)
|
||||
# dev.jinemi.com -> Forgejo, CANONICAL: both its ROOT_URL and the
|
||||
# registry host in every image name
|
||||
# git.thermograph.org -> Forgejo, same site block — still served, now
|
||||
# only for already-pushed image tags carrying the
|
||||
# old prefix and for runners registered against
|
||||
# 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.
|
||||
# dev.jinemi.com -> Forgejo, CANONICAL (Forgejo's ROOT_URL)
|
||||
# git.thermograph.org -> Forgejo, same site block — still served and
|
||||
# still needed: the registry host in image names,
|
||||
# the mesh /etc/hosts pins and the runners' URLs
|
||||
# all use it (see deploy/forgejo/caddy-git.conf)
|
||||
# dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf)
|
||||
#
|
||||
# and hosts the **dev** environment at dev.thermograph.org (below).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# 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)
|
||||
#
|
||||
# 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
|
||||
# Let's Encrypt cert on first request and auto-renews. Nothing else to do for
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ fi
|
|||
# a tag from -- the caller (the deploying repo's deploy.yml) exports its own
|
||||
# service's tag (BACKEND_IMAGE_TAG or FRONTEND_IMAGE_TAG = sha-<12 hex> of the
|
||||
# app commit, or a semver tag).
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-dev.jinemi.com}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-git.thermograph.org}"
|
||||
export REGISTRY_HOST BACKEND_IMAGE_PATH FRONTEND_IMAGE_PATH
|
||||
|
||||
# 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
|
||||
# daemon from EVERY backend deploy, regardless of what the real backend
|
||||
# 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
|
||||
echo "==> $daemon_img predates the daemon binary; rolling without the daemon service this run"
|
||||
kept=()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
— 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`,
|
||||
|
|
@ -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
|
||||
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
|
||||
image name, `REGISTRY_HOST` default, runner label and `--add-host` pin under
|
||||
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.
|
||||
**Registry: deliberately not done.** Renaming the registry host is a separate
|
||||
migration from renaming the web UI, and the two need not happen together.
|
||||
|
||||
`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
|
||||
either name would re-expose the registry API publicly and undo hazard #15.
|
||||
Whatever else changes, keep the two names in one block.
|
||||
|
||||
**`git.thermograph.org` must stay served.** The list of reasons is shorter than
|
||||
it was, but it is not empty, and neither remaining item is a bookmark:
|
||||
**`git.thermograph.org` must stay served.** It is not a courtesy redirect for
|
||||
old bookmarks — CI resolves it:
|
||||
|
||||
- image tags already pushed under the old prefix name that host, so a rollback
|
||||
to one asks for `git.thermograph.org/...` even though nothing builds that
|
||||
name any more;
|
||||
- images are named by registry host, and the `git.thermograph.org/` prefix is
|
||||
baked into the runner labels (`register-lan-runner.sh`,
|
||||
`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
|
||||
(`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.
|
||||
(`https://git.thermograph.org`); they keep working while that name resolves.
|
||||
|
||||
### 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
|
||||
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
|
||||
vps1's public IP — otherwise Caddy's `/v2/*` block above refuses the
|
||||
connection. Public DNS resolves both names to vps1's public IP, so add an
|
||||
`/etc/hosts` override on each such node pinning them to vps1's WireGuard
|
||||
address instead:
|
||||
vps2 pulling app images) must reach `git.thermograph.org` **over the
|
||||
WireGuard tunnel**, not vps1's public IP — otherwise Caddy's `/v2/*` block
|
||||
above refuses the connection. Public DNS resolves the domain to vps1's public
|
||||
IP, so add a `/etc/hosts` override on each such node pinning it to vps1's
|
||||
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
|
||||
numbering — adjust if you assigned it differently.) The git/web UI keeps
|
||||
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
|
||||
independent jobs and needs the pin for each: it is the host in every image
|
||||
name, *and* it is `ROOT_URL`, from which Forgejo derives the registry's
|
||||
bearer-token realm — so a client is sent to `https://<ROOT_URL host>/v2/token`
|
||||
to collect a token no matter which name it dialled. `git.thermograph.org` stays
|
||||
pinned for a third reason: image tags pushed before the migration name it, and
|
||||
a rollback to one of those dials it directly.
|
||||
**Pin the canonical name too, not just the image host.** Forgejo derives the
|
||||
registry's bearer-token realm from `ROOT_URL`, so a client that already
|
||||
resolves `git.thermograph.org` over the mesh is still sent to
|
||||
`https://<ROOT_URL host>/v2/token` to collect a token. When `ROOT_URL` became
|
||||
`dev.jinemi.com`, that second request took the public route, the `/v2/*`
|
||||
matcher above answered 403, and docker fell back to anonymous — every push then
|
||||
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
|
||||
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.
|
||||
```
|
||||
echo "10.10.0.2 git.thermograph.org dev.jinemi.com" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
The realm moves whenever `ROOT_URL` does, so read it rather than assuming:
|
||||
|
||||
```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
|
||||
|
|
@ -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
|
||||
(`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
|
||||
with `write:package` scope — the embedded git-remote token lacks it, same
|
||||
requirement documented in `build-push.yml`):
|
||||
|
||||
```bash
|
||||
docker build -t dev.jinemi.com/jinemi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
|
||||
docker push dev.jinemi.com/jinemi/thermograph/ci-runner:vN
|
||||
docker build -t git.thermograph.org/jinemi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
|
||||
docker push git.thermograph.org/jinemi/thermograph/ci-runner:vN
|
||||
```
|
||||
|
||||
`register-lan-runner.sh`'s `LABELS` default points at the current tag, so
|
||||
|
|
@ -321,9 +291,8 @@ independently.
|
|||
|
||||
```bash
|
||||
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://dev.jinemi.com/v2/ # 403 from anywhere off the WireGuard mesh
|
||||
curl -I https://git.thermograph.org/ # 200 too — the alias must stay served
|
||||
curl -I https://git.thermograph.org/ # 200, valid cert (Caddy's, not a new one)
|
||||
curl -I https://git.thermograph.org/v2/ # 403 from anywhere off the WireGuard mesh
|
||||
# On the runner host, after registering the runner:
|
||||
systemctl --user status forgejo-runner # active, both labels registered
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# it ends up as a confusing orphaned comment in production config with no
|
||||
# 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
|
||||
# with the registry API open to the public internet.
|
||||
#
|
||||
# dev.jinemi.com is CANONICAL, now on both axes — it is Forgejo's ROOT_URL
|
||||
# (docker-stack.yml, FORGEJO_DOMAIN), so it is the name Forgejo puts in clone
|
||||
# URLs, the OAuth callback, webhook payload URLs and mail links; and it is the
|
||||
# registry host in every image name this repo builds or pulls.
|
||||
# dev.jinemi.com is CANONICAL — it is Forgejo's ROOT_URL (docker-stack.yml,
|
||||
# FORGEJO_DOMAIN), so it is the name Forgejo puts in clone URLs, the OAuth
|
||||
# callback, webhook payload URLs and mail links.
|
||||
#
|
||||
# git.thermograph.org is kept as a served alias, NOT as dead weight, though the
|
||||
# reason narrowed when the registry moved. What still needs it: image tags
|
||||
# already pushed under the old prefix (a rollback to one names that host), and
|
||||
# runners registered against `https://git.thermograph.org`, which hold the
|
||||
# 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
|
||||
# git.thermograph.org is kept as a served alias, NOT as dead weight: the
|
||||
# registry host baked into image names, the mesh /etc/hosts pins and the
|
||||
# runners' registered instance URL all still say git.thermograph.org. Removing
|
||||
# this name from the block breaks CI, not just old bookmarks. Both names get
|
||||
# their own Let's Encrypt cert automatically (HTTP-01); both A records point
|
||||
# at vps1. See README.md, "Migrating the domain".
|
||||
#
|
||||
|
|
@ -36,7 +33,7 @@
|
|||
|
||||
# --- copy from here down into /etc/caddy/Caddyfile ---
|
||||
|
||||
dev.jinemi.com, git.thermograph.org {
|
||||
git.thermograph.org, dev.jinemi.com {
|
||||
encode zstd gzip
|
||||
|
||||
@registry_external {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@
|
|||
# class, and skips the per-job install entirely.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# in ~/forgejo-runner/.runner on the runner host directly (same runner
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
# box). A second reverse proxy binding those same ports would either fail 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
|
||||
# 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
|
||||
# (HTTP-01), not a second ACME flow.
|
||||
#
|
||||
|
|
@ -82,8 +82,7 @@ services:
|
|||
FORGEJO__database__USER: forgejo
|
||||
# Canonical domain. Forgejo has exactly ONE ROOT_URL and derives every
|
||||
# absolute URL from it — clone URLs, the Google OAuth callback, webhook
|
||||
# payload URLs, mail links, and the registry's bearer-token realm.
|
||||
# git.thermograph.org is still served (Caddy
|
||||
# payload URLs, mail links. git.thermograph.org is still served (Caddy
|
||||
# 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
|
||||
# longer the name Forgejo generates.
|
||||
|
|
@ -95,12 +94,9 @@ services:
|
|||
# git.thermograph.org one are registered; verify with a probe before
|
||||
# changing this again (deploy/forgejo/README.md, "Migrating the domain").
|
||||
#
|
||||
# The registry host in image names and the mesh /etc/hosts pins now say
|
||||
# dev.jinemi.com too, so ROOT_URL and the registry name agree again — which
|
||||
# matters, because Forgejo derives the registry's bearer-token realm from
|
||||
# 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.
|
||||
# NOT changed by this: the registry host baked into image names, the mesh
|
||||
# /etc/hosts pins, and the runners' registered instance URL all still say
|
||||
# git.thermograph.org. That is a separate migration.
|
||||
FORGEJO__server__DOMAIN: "${FORGEJO_DOMAIN:-dev.jinemi.com}"
|
||||
FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN:-dev.jinemi.com}/"
|
||||
FORGEJO__server__SSH_PORT: "2222"
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ set -euo pipefail
|
|||
FORGEJO_URL="${1:?usage: $0 <forgejo_url> <registration_token>}"
|
||||
TOKEN="${2:?}"
|
||||
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"
|
||||
systemctl --user stop github-actions-runner 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -26,14 +26,12 @@ documentation one.
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- vps2's docker daemon is logged in to `dev.jinemi.com`
|
||||
(`/root/.docker/config.json`), so the daemon can pull the job image. A login
|
||||
against the old `git.thermograph.org` does not carry over — docker keys
|
||||
credentials by registry host, so this needs its own `docker login`.
|
||||
- `dev.jinemi.com` resolves to the **mesh** address from vps2 for registry
|
||||
- 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.
|
||||
- `git.thermograph.org` resolves to the **mesh** address from vps2 for registry
|
||||
traffic. Job containers get this via `--add-host` in `config.yaml`; the host
|
||||
daemon needs the same pin in `/etc/hosts`.
|
||||
- The job image exists: `dev.jinemi.com/jinemi/thermograph/ci-runner:v2`.
|
||||
daemon already has it.
|
||||
- The job image exists: `git.thermograph.org/jinemi/thermograph/ci-runner:v2`.
|
||||
Built from `../ci-runner/Dockerfile`; see that file for when to rebuild.
|
||||
|
||||
## 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 \
|
||||
code.forgejo.org/forgejo/runner:6.3.1 \
|
||||
forgejo-runner register --no-interactive \
|
||||
--instance https://dev.jinemi.com \
|
||||
--instance https://git.thermograph.org \
|
||||
--token "$REG_TOKEN" \
|
||||
--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
|
||||
```
|
||||
|
||||
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
|
||||
`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
|
||||
|
|
@ -155,6 +147,5 @@ only, and the job container has no other way to learn the mesh address.
|
|||
from the compose file.
|
||||
|
||||
**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` for a runner registered before the migration) —
|
||||
check egress from vps2 and that Forgejo on vps1 is serving that name.
|
||||
`https://git.thermograph.org` — check egress from vps2 and that Forgejo on vps1
|
||||
is serving.
|
||||
|
|
|
|||
|
|
@ -25,26 +25,18 @@ runner:
|
|||
shutdown_timeout: 3h
|
||||
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
# `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
|
||||
# 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.
|
||||
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:
|
||||
# they check out into the job container's own workspace and reach the estate
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
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_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:
|
||||
age:
|
||||
- enc: |
|
||||
|
|
@ -18,7 +22,7 @@ sops:
|
|||
JEUSbfXvUHvuMyY94DhbXmh2WgqxWYQuvF9UrDLeDw14tNPIGoIpFg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-25T01:02:49Z"
|
||||
mac: ENC[AES256_GCM,data:HsxO525FU/AfZw6Y5nTrfPIefl1InTV4M7e03hGsxZRHS2Fwsdqiyl/swI2oUFt3tfEyn0RwNB9cEeG/yo4UPPY80E5wnBvv0kaV0gfibBNwBRuIbPJZR/vXm6k8+60shcCOrtvpby4zUmtuk84kEK4mHWpVzh4xs5eYY37la6I=,iv:yR1Zq5TusAdzt7JnarXqRU3X896JMXJzrJw7p6tTzWs=,tag:lK9ph8OJyF/W1E4CLX8AGw==,type:str]
|
||||
lastmodified: "2026-08-01T22:40:50Z"
|
||||
mac: ENC[AES256_GCM,data:0ru6lJTTjI5aqGsPYrT3ku2jX0sL32W57LBzfmauf/Td32RXqSNE7cdNlLNwoWsdi/moiKzMexKFkWRBbDzTQprGcBOSbjg51g2n7czHEFnkehH1ixfxaunkVRO9chsude+IEndS9o8x5H+/HZA7NVFFEf2Era0DSeY9IIi0i/U=,iv:mmtmQp5DBAt2aahW5bIhKVY59AYeu60hVfrT7Dfe61c=,tag:FkvAiGbbrWdUTa5fJ8v59A==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export STACK_ENV_FILE
|
|||
# --- image tags -----------------------------------------------------------------
|
||||
# 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.
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-dev.jinemi.com}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-git.thermograph.org}"
|
||||
export REGISTRY_HOST
|
||||
TAGS_FILE="$APP_DIR/infra/deploy/.stack-image-tags.env"
|
||||
_incoming_backend="${BACKEND_IMAGE_TAG:-}"
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
|
||||
services:
|
||||
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"]
|
||||
environment:
|
||||
# Beta's OWN role and OWN database on the shared instance. `db` resolves
|
||||
|
|
@ -108,7 +108,7 @@ services:
|
|||
failure_action: rollback
|
||||
|
||||
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"]
|
||||
environment:
|
||||
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta
|
||||
|
|
@ -144,7 +144,7 @@ services:
|
|||
condition: on-failure
|
||||
|
||||
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"]
|
||||
environment:
|
||||
THERMOGRAPH_ROLE: lake
|
||||
|
|
@ -176,7 +176,7 @@ services:
|
|||
failure_action: rollback
|
||||
|
||||
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
|
||||
# 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.
|
||||
|
|
@ -218,7 +218,7 @@ services:
|
|||
failure_action: rollback
|
||||
|
||||
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"]
|
||||
# REQUIRED: overriding `entrypoint:` with no `command:` drops the image's
|
||||
# CMD entirely, and env-entrypoint.sh's fallback (`exec uvicorn app:app`)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ services:
|
|||
condition: on-failure
|
||||
|
||||
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"]
|
||||
environment:
|
||||
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph
|
||||
|
|
@ -109,7 +109,7 @@ services:
|
|||
failure_action: rollback
|
||||
|
||||
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"]
|
||||
environment:
|
||||
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
|
||||
# an accelerator, never a point of failure.
|
||||
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"]
|
||||
environment:
|
||||
THERMOGRAPH_ROLE: lake
|
||||
|
|
@ -186,7 +186,7 @@ services:
|
|||
# (/usr/local/bin/thermograph-daemon, built into the backend image) and the
|
||||
# app share the /internal/* API contract, so one BACKEND_IMAGE_TAG rolls
|
||||
# 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
|
||||
# deploy/entrypoint.sh (Alembic + uvicorn) whenever it exists, which is
|
||||
# exactly what this service must never run — migrations belong to the
|
||||
|
|
@ -243,7 +243,7 @@ services:
|
|||
failure_action: rollback
|
||||
|
||||
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"]
|
||||
# REQUIRED, not cosmetic: overriding `entrypoint:` with no `command:` drops
|
||||
# the image's own CMD entirely (Docker/Swarm semantics, not merged) --
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ if ! ufw status | grep -q "^Status: active"; then
|
|||
echo "ports above. Before enabling, explicitly allow every port this node"
|
||||
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 "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 "what's actually listening first. Enabling ufw without doing this WILL"
|
||||
echo "drop live traffic the moment it activates."
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
PORT=8137
|
||||
|
||||
# --- 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
|
||||
# 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
|
||||
|
|
@ -243,11 +243,3 @@ THERMOGRAPH_BASE_URL=https://thermograph.org
|
|||
# required — Discord delivers content for mentions/DMs. Unset/0 => no gateway
|
||||
# connection. (Code: thermograph-backend notifications/discord_bot.py.)
|
||||
#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
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ services:
|
|||
# builds `jinemi/thermograph/backend:local` from the backend repo (see
|
||||
# infra Makefile) and this pulls/uses it. BACKEND_IMAGE_PATH/TAG are
|
||||
# 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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -180,7 +180,7 @@ services:
|
|||
# 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.
|
||||
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:
|
||||
THERMOGRAPH_ROLE: lake
|
||||
PORT: 8141
|
||||
|
|
@ -207,7 +207,7 @@ services:
|
|||
# share the /internal/* API contract, so they must roll together — a
|
||||
# separate tag could skew them. It owns the Discord gateway websocket and
|
||||
# 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
|
||||
# backend service's boot already owns migrations — two containers racing
|
||||
# `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
|
||||
# directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent
|
||||
# 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
|
||||
# wait for a real, healthy backend, not just a started container.
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`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
|
||||
`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
|
||||
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
|
||||
`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 is never sent over plain HTTP) and `1` behind Caddy TLS.
|
||||
- The rendered Caddyfile only reverse-proxies the app. The repo's
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ resource "null_resource" "host" {
|
|||
. deploy/render-secrets.sh
|
||||
render_thermograph_secrets ${var.app_dir}
|
||||
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
|
||||
docker compose ${local.compose_flags} pull backend frontend
|
||||
docker compose ${local.compose_flags} up -d --remove-orphans
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ hosts = {
|
|||
# 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
|
||||
# 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
|
||||
# environment on the same host.
|
||||
domain = ""
|
||||
|
|
@ -151,13 +151,13 @@ hosts = {
|
|||
# }
|
||||
|
||||
# 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
|
||||
# frontend_port = 8080
|
||||
#
|
||||
# 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:
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -127,9 +127,9 @@ variable "gcp_hosts" {
|
|||
}
|
||||
|
||||
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
|
||||
default = "https://dev.jinemi.com/admin_emi/thermograph-infra.git"
|
||||
default = "https://git.thermograph.org/admin_emi/thermograph-infra.git"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
- 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
|
||||
`docker compose up -d`. Login is locked to pre-provisioned accounts
|
||||
|
|
|
|||
Loading…
Reference in a new issue