Commit graph

444 commits

Author SHA1 Message Date
Emi Griffith
fedd18a74b Make trace-rain days read consistently across every surface
Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis
"trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and
several surfaces still treated it as dry — a day would read "0.0" · Light rain,
N days since rain" at once. Align every consumer of a graded precip day with the
> 0 split so a trace day reads as the (very light) rain it was graded to be.

- Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not
  the 0.01" rain-frequency line, so a trace day breaks the streak and its
  "days since rain" no longer keeps climbing. (The rain_freq climatology stat
  keeps the 0.01" measurable-rain convention.)
- Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth
  rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip,
  the day-page observation + ladder marker, and the recent/forecast table.
- weatherType() decides wet/dry from the grade class when it has it (a rain tier
  means it rained even at trace depth), falling back to depth > 0; callers on the
  calendar and day page pass the class.
- Recent-table and chart precip dots key their dry-vs-rain rendering on the grade
  class instead of value > 0, so a trace day tints as rain, not dry.

Rain chart fan: the precipitation fan still used the pre-split colour map,
painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier
too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan)
and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90
Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 16:07:14 -07:00
emi
248eeee110 Gate MET forecast days on diurnal sample coverage (#63)
All checks were successful
secrets-guard / encrypted (push) Successful in 10s
shell-lint / shellcheck (push) Successful in 12s
secrets-guard / encrypted (pull_request) Successful in 10s
PR build (required check) / changes (pull_request) Successful in 14s
shell-lint / shellcheck (pull_request) Successful in 11s
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m41s
Deploy backend to LAN dev server / build (push) Successful in 1m48s
PR build (required check) / build-frontend (pull_request) Successful in 1m23s
PR build (required check) / build-backend (pull_request) Successful in 1m38s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Successful in 33s
2026-07-24 22:23:51 +00:00
emi
008052b412 Merge pull request 'observability: add the estate's first alerting; supervise Postfix' (#58) from ops/mail-supervision-and-alerting into dev
All checks were successful
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 8s
Validate observability stack / validate (push) Successful in 13s
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Successful in 36s
PR build (required check) / build-frontend (pull_request) Successful in 1m6s
PR build (required check) / gate (pull_request) Successful in 2s
2026-07-24 20:44:21 +00:00
Emi Griffith
e4693dce58 observability: add the estate's first alerting; supervise Postfix
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 8s
shell-lint / shellcheck (pull_request) Successful in 10s
PR build (required check) / changes (pull_request) Successful in 16s
PR build (required check) / validate-observability (pull_request) Successful in 18s
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
The estate had zero alert rules. The only contact point was Grafana's factory
default pointing at the literal string <example@email.com>, and beta's
untracked compose override routed Grafana's SMTP at prod's Postfix — so
alerts, had any existed, would have been delivered by the box most likely to
be on fire. Prod served 86 5xx in 24h including five /healthz failures and
nobody was told.

Alerting (deploys to beta, which is where Grafana runs):
- 12 Loki-based rules. There is no Prometheus in this estate and Loki is the
  only datasource, so every rule is log-derived.
- Thresholds come from a 24h backtest that happens to contain a real ~20min
  prod outage at 04:20Z. Absolute counts, not ratios: prod runs ~7 req/min, so
  one bad request is 1.4% and a ratio alert would scream all night. The 5xx
  burst rule fires on the outage's 45 and 22 buckets and on nothing else in
  the day; the largest benign bucket all day was 5.
- Routed to a new private #ops-alerts channel, not to any existing channel —
  #weather-events, #announcements and #prod are product surfaces that notify
  real subscribers.
- AlertingWatchdog is a dead-man's switch; its value is its absence.
- Delivery is proven, not assumed: the rules were provisioned into a throwaway
  Grafana against beta's live Loki and a real alert arrived in Discord. This
  matters because GET /api/v1/provisioning/contact-points returns [REDACTED]
  for the URL — a contact point holding an uninterpolated env var looks
  perfectly healthy and pages nobody. The only proof is a message arriving.

CI gains a structural check, because the existing one only proves YAML parses:
an alert rule whose condition names a missing refId is valid YAML, provisions
cleanly, and never fires. It also hard-fails on a literal Discord webhook in
the repo. Verified against all three breakages deliberately introduced.

Postfix supervision:
- The 13h outage was a boot-ordering race, not a Docker renumbering: postfix
  started at 08:09:49, wg0 came up at :51, postfix fataled at :52 on a missing
  docker_gwbridge address, and dockerd did not finish starting until 08:10:16.
  Stock postfix@.service is ordered only After=network-online.target and ships
  no Restart=, so one lost race became a permanent outage.
- An ExecStartPre gate now blocks up to 60s until every inet_interfaces
  address actually exists, which absorbs the transient case inside a single
  start attempt. That makes bounded retry correct: 5 attempts in 600s, then
  failed — a genuinely broken config reaches a visible failed state in ~100s
  instead of re-fataling every 15s forever.
- A 5-minute watchdog timer retries indefinitely and runs reset-failed, so
  "failed" still self-heals. Worst case is ~5 minutes, not 13 hours.
- Wants=, not Requires=: a dockerd failure must not take down the loopback and
  mesh listeners that do not depend on Docker at all.

Health checks must read config with `postconf -c`, never postmulti/postqueue/
postfix — those three RESOLVE inet_interfaces and so fatal precisely when an
address is missing, which made the first version of this check report
status=ok bound=0/0. A health check that fails open is worse than none.

DEPLOY.md carries the monitoring contract, including that systemctl is-active
postfix is a known-false signal: postfix.service is a wrapper whose
ExecStart=/bin/true, so it reports active forever while the real postfix@-
instance is failed with zero listeners. Reproduced live.

Known gap, documented not fixed: Alloy ships Docker stdout, Caddy files and
app JSONL, not journald — so no Postfix line reaches Loki and the mail rules
cannot fire until loki.source.journal is added.

Claude-Session: https://claude.ai/code/session_0182KTMrsTHJc3TcewCatJFY
2026-07-24 13:19:19 -07:00
emi
519541c378 Merge pull request 'Off-box encrypted backups to Contabo Object Storage' (#14) from feature/storage-backups into dev
All checks were successful
secrets-guard / encrypted (push) Successful in 5s
shell-lint / shellcheck (push) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 46s
PR build (required check) / gate (pull_request) Successful in 3s
2026-07-24 20:08:13 +00:00
Emi Griffith
e91b49a000 Merge remote-tracking branch 'origin/dev' into feature/storage-backups-merged
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
# Conflicts:
#	infra/deploy/secrets/beta.yaml
#	infra/deploy/secrets/prod.yaml
2026-07-24 13:07:10 -07:00
emi
cda6e75b38 data/climate: fix geocode_nominatim's NameError on every call (P0, live) (#51)
All checks were successful
secrets-guard / encrypted (push) Successful in 14s
shell-lint / shellcheck (push) Successful in 13s
PR build (required check) / changes (pull_request) Successful in 10s
secrets-guard / encrypted (pull_request) Successful in 10s
shell-lint / shellcheck (pull_request) Successful in 10s
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m24s
Deploy backend to LAN dev server / build (push) Successful in 1m49s
PR build (required check) / build-frontend (pull_request) Successful in 1m21s
PR build (required check) / build-backend (pull_request) Successful in 1m35s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Successful in 22s
2026-07-24 19:45:49 +00:00
emi
5f0f5990ae Merge pull request 'render-secrets: drop the RETURN trap, it broke deploy silently' (#50) from shell-lint into dev
All checks were successful
secrets-guard / encrypted (push) Successful in 5s
shell-lint / shellcheck (push) Successful in 8s
2026-07-24 19:39:39 +00:00
Emi Griffith
ca7bae5933 Merge remote-tracking branch 'origin/dev' into shell-lint-merged
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 9s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 3s
2026-07-24 12:38:13 -07:00
emi
67d1734e99 ops/iceberg.sh: read-only Iceberg lake queries across all environments (#23)
All checks were successful
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 7s
2026-07-24 19:34:30 +00:00
emi
bf0aaf1ec0 Pre-warm the SEO content derived-store off-request (#49)
All checks were successful
secrets-guard / encrypted (push) Successful in 10s
shell-lint / shellcheck (push) Successful in 13s
Deploy backend to LAN dev server / build (push) Successful in 1m40s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m47s
Deploy backend to LAN dev server / deploy (push) Successful in 30s
After a cell's archive advances, the first content request recomputes a 45yr payload cold. Add warm_cities.warm_content — idempotent, cache-only, bounded — that pre-populates the content derived-store under the same (kind,key,content_token) the routes read, wired into the leader-gated notifier loop (50 cities/30-min tick) so the ~1000-city set stays warm within hours of each daily advance.
2026-07-24 19:31:08 +00:00
emi
6fef57f05f Check derived store before loading history on content routes (#48)
Some checks failed
Deploy backend to LAN dev server / deploy (push) Blocked by required conditions
Build + push backend image (Forgejo registry) / build-push (push) Has been cancelled
Deploy backend to LAN dev server / build (push) Has been cancelled
shell-lint / shellcheck (push) Has been cancelled
secrets-guard / encrypted (push) Has been cancelled
The three content handlers loaded the full ~45yr archive before checking the cache, so even hits paid the load. Restructure to resolve cell + compute content_token cheaply first, check the store, and load history only inside the miss-only build() closure. Cache hits and 304s now return without touching the archive. Keys/kinds/ETag unchanged.
2026-07-24 19:30:57 +00:00
emi
a4fa72639f Add cheap, stable content-page cache token (#47)
Some checks failed
Deploy backend to LAN dev server / deploy (push) Blocked by required conditions
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 7s
Build + push backend image (Forgejo registry) / build-push (push) Has been cancelled
Deploy backend to LAN dev server / build (push) Has been cancelled
Content pages keyed cache validity on the full-history hist_end, invalidating ~1-2x/day and paying a 45yr load even to compute the token. Add content_token(cell_id) = PAYLOAD_VER:CONTENT_VER:max_date, backed by an indexed MAX(date) (climate_store.history_max_date / climate.history_max_date), so it survives intra-day top-ups and needs no full-history load.
2026-07-24 19:30:38 +00:00
emi
864d38d772 ops/dbq.sh: read-only Postgres queries across all environments (#18)
All checks were successful
secrets-guard / encrypted (push) Successful in 16s
shell-lint / shellcheck (push) Successful in 17s
2026-07-24 19:28:58 +00:00
emi
4ff113f2cf Stop logging /healthz + internal SSR hop, truncate logged IPs (#35)
Some checks failed
secrets-guard / encrypted (push) Has been cancelled
shell-lint / shellcheck (push) Has been cancelled
Deploy backend to LAN dev server / build (push) Successful in 1m22s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m24s
Deploy backend to LAN dev server / deploy (push) Successful in 19s
2026-07-24 19:28:47 +00:00
emi
0f1b3a168c Lake /query reads era5_daily through Iceberg metadata (#44)
All checks were successful
shell-lint / shellcheck (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 9s
PR build (required check) / changes (pull_request) Successful in 14s
secrets-guard / encrypted (pull_request) Successful in 12s
shell-lint / shellcheck (pull_request) Successful in 13s
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m27s
Deploy backend to LAN dev server / build (push) Successful in 1m43s
PR build (required check) / build-frontend (pull_request) Successful in 1m23s
PR build (required check) / build-backend (pull_request) Successful in 1m31s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Successful in 25s
2026-07-24 19:09:22 +00:00
emi
578fb563a4 Roll the daemon with backend deploys in the Swarm stack (#40)
All checks were successful
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 7s
PR build (required check) / changes (pull_request) Successful in 9s
secrets-guard / encrypted (pull_request) Successful in 8s
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Successful in 23s
PR build (required check) / build-frontend (pull_request) Successful in 57s
PR build (required check) / gate (pull_request) Successful in 2s
2026-07-24 18:59:46 +00:00
emi
0862395dda Log hygiene: Alloy CPU, Loki chunks/limits, Caddy field-stripping (#36)
All checks were successful
secrets-guard / encrypted (push) Successful in 5s
shell-lint / shellcheck (push) Successful in 7s
Validate observability stack / validate (push) Successful in 12s
2026-07-24 04:37:41 +00:00
emi
eaaa4f1ad9 Fix lake extension bake user; disable the daemon healthcheck in the stack (#30)
Some checks failed
secrets-guard / encrypted (push) Successful in 5s
shell-lint / shellcheck (push) Successful in 18s
PR build (required check) / changes (pull_request) Successful in 9s
secrets-guard / encrypted (pull_request) Successful in 10s
shell-lint / shellcheck (pull_request) Successful in 8s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m12s
Deploy backend to LAN dev server / build (push) Successful in 1m27s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m17s
PR build (required check) / build-frontend (pull_request) Successful in 1m8s
PR build (required check) / gate (pull_request) Successful in 2s
Deploy backend to LAN dev server / deploy (push) Failing after 1m55s
2026-07-24 03:47:39 +00:00
emi
4e3c7e82c6 Merge pull request 'Reconcile: merge main (shellcheck, Go daemon) into dev' (#27) from reconcile/main-into-dev into dev
Some checks failed
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m15s
Deploy backend to LAN dev server / build (push) Successful in 1m23s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m32s
secrets-guard / encrypted (push) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 14s
shell-lint / shellcheck (push) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 8s
Deploy frontend to LAN dev server / build (push) Successful in 51s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 53s
PR build (required check) / gate (pull_request) Successful in 2s
Deploy backend to LAN dev server / deploy (push) Failing after 1m54s
Deploy frontend to LAN dev server / deploy (push) Successful in 2m0s
2026-07-24 00:09:03 +00:00
Emi Griffith
30b0c70bbb Reconcile: merge main (shellcheck guard, Go daemon) into dev
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 8s
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m9s
PR build (required check) / build-backend (pull_request) Successful in 1m44s
PR build (required check) / gate (pull_request) Successful in 2s
main took two direct PRs (#19 shell-lint, #21 the thermograph-daemon Go
service) while dev accumulated the ERA5 lake stack; both sides added compose/
stack services and touched the same seams. Resolutions keep both worlds:

- compose + Swarm stack: lake AND daemon are sibling services; backend env
  carries THERMOGRAPH_LAKE_URL and THERMOGRAPH_INTERNAL_TOKEN.
- deploy.sh: backend deploys roll (backend lake daemon); all adds frontend.
- requirements.txt: websockets/apscheduler stay removed (moved to the Go
  daemon), duckdb stays (the lake role's engine).

Merged tree: backend suite green, both compose configs validate.
2026-07-23 17:06:52 -07:00
emi
16bddb6625 Iceberg conversion container for the ERA5 lake (infra/lake-iceberg) (#24)
All checks were successful
secrets-guard / encrypted (push) Successful in 16s
PR build (required check) / changes (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m20s
PR build (required check) / build-backend (pull_request) Successful in 1m28s
PR build (required check) / gate (pull_request) Successful in 3s
2026-07-23 22:56:27 +00:00
emi
6396d553d2 Own /state in the image; lake cache degrades instead of failing (#25)
All checks were successful
secrets-guard / encrypted (push) Successful in 12s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 57s
Deploy backend to LAN dev server / build (push) Successful in 1m13s
Deploy backend to LAN dev server / deploy (push) Successful in 18s
2026-07-23 22:55:41 +00:00
emi
2d3f37c474 daemon: move the Discord gateway and scheduler out of the web process into Go (#21)
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 8s
Sync infra to hosts / sync-prod (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 8s
shell-lint / shellcheck (push) Successful in 10s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m14s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
The gateway bot and APScheduler were long-lived stateful I/O loops running
inside the async web app under a leader election. They move into a single Go
binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers.

It owns no grading logic. Anything needing data calls back over a new
internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading
depends on polars and the parquet cache; reimplementing it in Go would let the
bot's grades drift from the API's. The grade route returns gateway-ready JSON
and Go relays the bytes verbatim.

The binary ships in the backend image and runs as a second compose service off
the same tag, so the two ends of the /internal/* contract can never skew.
deploy.sh rolls daemon alongside backend -- without that the service would never
be created, since a single-service deploy uses --no-deps. It also probes the
image first and skips the daemon when rolling a tag that predates the binary:
infra tracks main while image tags are env-staged, so a host can legitimately be
asked to roll an older backend image, and creating the service anyway would
leave a container crash-looping on a missing binary.

replicas: 1 with order: stop-first replaces the leader election -- Discord
permits one gateway connection per bot token.

THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from
THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs
no new vault entry. The derivation is pinned to a shared cross-language test
vector asserted on both sides, so drift fails CI instead of 401ing every call.
Fail closed when neither secret is set.

Improvements over the Python: a close intended for RESUME uses 4000 rather than
1000 (Discord invalidates a session closed 1000, so the old default defeated its
own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO
returns an error rather than a clean reconnect, which would otherwise reset
backoff and hot-loop against the gateway.

365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
emi
a787f79f92 Survive Contabo PUT throttling in the lake extractor (#22)
All checks were successful
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m34s
secrets-guard / encrypted (push) Successful in 7s
Deploy backend to LAN dev server / build (push) Successful in 1m37s
Deploy backend to LAN dev server / deploy (push) Successful in 18s
2026-07-23 22:47:26 +00:00
emi
8e09155aaf Run the ERA5 lake service in every environment (#20)
All checks were successful
secrets-guard / encrypted (push) Successful in 6s
2026-07-23 22:32:13 +00:00
emi
de8e847f9f shell: add shellcheck CI guard and drive the tree to zero findings (#19)
All checks were successful
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m23s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m22s
Sync infra to hosts / sync-beta (push) Successful in 9s
Sync infra to hosts / sync-prod (push) Successful in 8s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 9s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
Deploy frontend to beta VPS / deploy (push) Successful in 1m8s
Adds .forgejo/workflows/shell-lint.yml (pinned shellcheck v0.11.0 + sha256, -x,
default severity, fail on any finding, not path-filtered) and drives all 26
scripts to zero findings.

Two defects shellcheck cannot see:

render-secrets.sh left DECRYPTED vault contents in /tmp whenever a sops decrypt
failed -- the caller's set -e aborted the function before either cleanup ran.
Now removed on every exit path, with `|| return 1` on both sops calls so a
decrypt failure can never write a partial /etc/thermograph.env regardless of the
caller's shell options. Explicitly not a `trap ... RETURN`: such a trap set in a
sourced function persists into the caller's shell and re-fires when the caller's
next `.`/source completes, where the function-local tmp is unset -- fatal and
silent under deploy.sh's set -u. The file now records that reasoning.

autoscale.sh ran `set -eu` without pipefail while piping docker stats into awk,
so a failed left side was swallowed and the loop autoscaled on empty input.
Promoted to pipefail with a missed sample treated as a skip; verified busybox ash
in docker:27-cli supports it.

Also: capture-fixtures.sh's `jq . || cat` ran cat after jq had consumed stdin,
silently writing truncated fixtures; deploy.sh/deploy-stack.sh `# shellcheck
source=` paths corrected for the monorepo layout.
2026-07-23 22:26:05 +00:00
Emi Griffith
56be0dd9b7 render-secrets: drop the RETURN trap, it broke deploy silently
All checks were successful
PR build (required check) / changes (pull_request) Successful in 9s
shell-lint / shellcheck (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 9s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Successful in 16s
PR build (required check) / build-backend (pull_request) Successful in 53s
PR build (required check) / gate (pull_request) Successful in 3s
The RETURN trap added in the previous commit leaked out of the function and
killed every deploy on a SOPS-configured host.

A RETURN trap set inside a SOURCED function is not function-scoped: it persists
in the caller's shell after the function returns, and a RETURN trap also fires
when a `.`/source completes. deploy.sh sources /etc/thermograph.env six lines
after calling render_thermograph_secrets, which re-fired the trap at top level
where `tmp` -- function-local -- is unset. Under deploy.sh's `set -u` that is
fatal, and silent: that line already sends stderr to /dev/null, so the deploy
rendered secrets and then died with no diagnostic before pulling or rolling
anything.

Replaced with explicit `rm -f "$tmp"` on each exit path, plus a comment
recording why the tidier-looking trap is wrong here so it doesn't come back.
The original defect the trap was meant to fix stays fixed: the decrypt-failure
path removes the plaintext temp file before returning 1.

The write section now captures its status in `rc` and cleans up once, rather
than ending on `rm` -- as the last command it was masking a failed in-place
`cat` write to status 0, so a half-written /etc/thermograph.env would have
deployed as if it succeeded.

Verified in a container against the real call pattern (strict-mode caller,
source lib, call, then source the rendered env): success path returns 0 and the
caller survives the subsequent source; decrypt-failure path aborts the caller
with no /etc/thermograph.env written; both leave zero temp files.
2026-07-23 15:03:17 -07:00
Emi Griffith
40a3ce21d9 shell: add shellcheck CI guard and drive the tree to zero findings
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 9s
PR build (required check) / changes (pull_request) Successful in 17s
shell-lint / shellcheck (pull_request) Successful in 13s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m27s
PR build (required check) / build-backend (pull_request) Successful in 1m36s
PR build (required check) / gate (pull_request) Successful in 3s
No static analysis has ever run over the ~2k lines of shell that deploy,
provision secrets, and bootstrap hosts as root over SSH. Add shell-lint.yml
(pinned shellcheck v0.11.0 + sha256, -x, default severity, fail on any
finding) and fix everything it reports, plus two defects it structurally
cannot see.

Not path-filtered, matching secrets-guard's call: a backstop that only runs
when you expect it to isn't a backstop. Scripts are discovered with find, so
new ones are covered on landing. The version is pinned to a static release
rather than apt's, so a drifted shellcheck can't fail CI on an unrelated push.

render-secrets.sh: the mktemp holding DECRYPTED vault contents was only
removed on the success path and one failure branch, so a sops decrypt failure
left plaintext POSTGRES_PASSWORD in /tmp indefinitely on a live host. A RETURN
trap makes removal unconditional, and the two sops calls now `|| return 1`
explicitly instead of relying on the caller's set -e (a bare set -e abort
skips the trap). The function stays free of `set -e` itself -- it is sourced,
and shell options would leak into the caller.

autoscale.sh: ran `set -eu` without pipefail while piping docker stats into
awk, so a failed left side was swallowed and the loop scaled on empty input.
Promoted to pipefail with avg_cpu's failure treated as a missed sample, so a
daemon hiccup can't kill the autoscaler. Verified busybox ash in docker:27-cli
supports pipefail and the script still parses there.

capture-fixtures.sh: `jq . || cat` ran cat after jq had already consumed
stdin, silently writing a truncated fixture; now a real if/else that fails
loudly. deploy.sh/deploy-stack.sh: `# shellcheck source=` paths corrected for
the monorepo layout, and /etc/thermograph.env marked unfollowable (it is
rendered at deploy time and cannot exist at lint time).
2026-07-23 14:59:47 -07:00
emi
21eea3a4c2 Parallelize lake tile uploads (#17)
All checks were successful
secrets-guard / encrypted (push) Successful in 7s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m2s
Deploy backend to LAN dev server / build (push) Successful in 1m12s
Deploy backend to LAN dev server / deploy (push) Successful in 21s
2026-07-23 21:32:58 +00:00
emi
d9537798c6 Vault the ERA5 lake bucket credentials (prod) (#16)
All checks were successful
secrets-guard / encrypted (push) Successful in 7s
2026-07-23 21:27:32 +00:00
emi
370a4ffdc1 ERA5 lake: bucket-hosted history primary + SQL indexer service (#15)
All checks were successful
secrets-guard / encrypted (push) Successful in 10s
Deploy backend to LAN dev server / build (push) Successful in 1m13s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m39s
Deploy backend to LAN dev server / deploy (push) Successful in 34s
2026-07-23 21:20:35 +00:00
Emi Griffith
6086b40fe6 Add off-box encrypted backups to Contabo Object Storage
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / changes (pull_request) Successful in 8s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 3s
Prod DB dumps and Forgejo (db + data volume) are now copied off-box to the
era5-thermograph S3 bucket, age-encrypted to the vault recipient so the host
age key at /etc/thermograph/age.key decrypts them for restore. Nothing plaintext
leaves the box; no local intermediate (streamed via age | rclone rcat).

ops-cron: the prod pg_dump job gains an off-box push; a new forgejo-backup job
dumps Forgejo's Postgres + tars its data volume on beta (the git host had NO
backup at all). 30-day off-box retention on both. S3 creds are Forgejo Actions
secrets (S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY), mirrored into the SOPS vault
(prod.yaml/beta.yaml) for host-side use. Contabo needs path-style addressing.

Adds deploy/backup/README.md with the DR restore runbook.
2026-07-23 13:42:09 -07:00
emi
ebf5efadfb Merge pull request 'Reconcile: merge main back into dev' (#13) from main into dev
All checks were successful
secrets-guard / encrypted (push) Successful in 7s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 58s
Deploy backend to LAN dev server / build (push) Successful in 1m7s
Deploy backend to LAN dev server / deploy (push) Successful in 17s
2026-07-23 16:22:20 +00:00
emi
0b574c88ed Merge pull request 'Promote dev -> main (deploy beta): Open-Meteo migration (Phases 0-4)' (#9) from dev into main
All checks were successful
secrets-guard / encrypted (push) Successful in 10s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m3s
Deploy backend to beta VPS / deploy (push) Successful in 2m7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 9s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 45s
PR build (required check) / gate (pull_request) Successful in 1s
2026-07-23 14:58:42 +00:00
emi
790b6bf0dc Migrate off the Open-Meteo weather API (Phases 0-4)
All checks were successful
secrets-guard / encrypted (push) Successful in 8s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m4s
Deploy backend to LAN dev server / build (push) Successful in 1m12s
Deploy backend to LAN dev server / deploy (push) Successful in 17s
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 54s
PR build (required check) / gate (pull_request) Successful in 3s
Steady-state usage of the Open-Meteo API is removed; it remains only as a dormant fallback. Geocoding -> local GeoNames + Nominatim; wind gusts -> Meteostat; history -> NASA POWER (curated cells seeded with keyless ERA5); recent+forecast -> NASA range + MET Norway. Plus a drift-check tool comparing NASA vs Open-Meteo. All keyless, no new infra. Backend suite green in-image (gate).
2026-07-23 14:34:51 +00:00
emi
6ac59a55ff Merge pull request 'Promote dev to main (tag-keying remainder)' (#7) from dev into main
All checks were successful
secrets-guard / encrypted (push) Successful in 5s
secrets-guard / encrypted (pull_request) Successful in 6s
2026-07-23 14:00:26 +00:00
emi
af839c6cd0 Finish the domain-sha tag keying: three deploy workflows were missed (#6)
All checks were successful
secrets-guard / encrypted (push) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 42s
PR build (required check) / gate (pull_request) Successful in 3s
2026-07-23 13:58:57 +00:00
emi
424930619a Merge pull request 'Promote dev to main (domain-sha tag keying)' (#4) from dev into main
All checks were successful
secrets-guard / encrypted (push) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 7s
2026-07-23 13:44:00 +00:00
emi
0b46844cf5 Key image tags to the last domain-touching commit, not the branch tip (#3)
All checks were successful
secrets-guard / encrypted (push) Successful in 5s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 9s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 54s
PR build (required check) / gate (pull_request) Successful in 3s
2026-07-23 13:42:28 +00:00
Emi Griffith
0ed7e89401 stack: fix bind-mount paths for the monorepo host layout
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 6s
Sync infra to hosts / sync-prod (push) Successful in 5s
secrets-guard / encrypted (push) Successful in 8s
The cutover moved the host checkout's deploy tree to /opt/thermograph/infra/deploy/,
but the stack file's absolute bind sources (db init, env-entrypoint.sh, autoscale.sh)
still pointed at the old /opt/thermograph/deploy/ -- a service update kept the stale
mounts and new tasks failed with 'bind source path does not exist'.
2026-07-23 03:34:25 -07:00
Emi Griffith
e5a4b25f71 backend: monorepo cutover marker (path-filter probe)
Some checks failed
Deploy backend to beta VPS / deploy (push) Failing after 10s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 34s
secrets-guard / encrypted (push) Successful in 5s
Deploy backend to prod VPS / deploy (push) Failing after 5m35s
2026-07-23 03:18:03 -07:00
Emi Griffith
f21f553d49 build.yml: run the hermetic suite inside the built image (promote dev's port to main, matching the split repos' dev->main promotion)
All checks were successful
secrets-guard / encrypted (push) Successful in 7s
2026-07-22 22:37:43 -07:00
Emi Griffith
303ab24938 Subtree-sync frontend to split main 510d94d (dev->main promotion: reconciled hardening, two-tier suite); subtree workflow copy stays deleted (CI lives at root) 2026-07-22 22:37:29 -07:00
Emi Griffith
303134479d Subtree-sync backend to split main a4d7fcd (dev->main promotion: reconciled bot + hardening, in-image CI); subtree workflow copies stay deleted (CI lives at root) 2026-07-22 22:37:21 -07:00
emi
510d94df96 Merge pull request 'Promote dev to main (reconciled bot + hardening merge, test harness, in-image CI)' (#7) from dev into main 2026-07-23 05:35:11 +00:00
emi
a4d7fcd1d7 Merge pull request 'Promote dev to main (reconciled bot + hardening merge, test harness, in-image CI)' (#15) from dev into main 2026-07-23 05:35:07 +00:00
Emi Griffith
67e4d651f5 Merge branch 'worktree-mono-assembly' into dev
All checks were successful
secrets-guard / encrypted (push) Successful in 6s
2026-07-22 22:27:09 -07:00
Emi Griffith
31b969039a docs: record the 2026-07-22 branch-migration sweep in cutover notes 2026-07-22 22:27:09 -07:00
Emi Griffith
a9ceb8f03f CI: port the dev-branch in-image test step into the reusable build check
Backend runs its full hermetic suite, frontend its unit tier, inside the
just-built image -- the dev-only feature both app repos carried in their own
build.yml (deleted here in favor of the root workflow).
2026-07-22 22:24:31 -07:00