stats: fix role-name quoting in the stats migration grants; add deploy/stats-collector (systemd user timers for CBS, Eurostat, FRED, BIS)
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
This commit is contained in:
28
deploy/stats-collector/README.md
Normal file
28
deploy/stats-collector/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# stats collector
|
||||||
|
|
||||||
|
Host-side collector for the public [/stats](https://stanrunge.dev/stats) section. It pulls open
|
||||||
|
statistics (no API keys) into the `stats` schema with `scripts/stats-fetch.ts`:
|
||||||
|
|
||||||
|
| source | timer | schedule (Europe/Amsterdam) | what |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) |
|
||||||
|
| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP, government finance…) |
|
||||||
|
| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices) |
|
||||||
|
| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates, monthly + daily |
|
||||||
|
|
||||||
|
Each timer starts `stats-collector@<source>.service`, which runs
|
||||||
|
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`
|
||||||
|
in `~/dev/stats-runner`, reads `DB_URL` (app role) from the running app container, and runs the
|
||||||
|
fetch. Every job is logged in `stats.fetch_run` (shown as "data freshness" on /stats).
|
||||||
|
|
||||||
|
Politeness: one shared User-Agent, a per-host gap between requests (CBS 250 ms ≈ 4 req/s,
|
||||||
|
OECD 3.1 s ≈ 19/min, FRED/BIS 1 s), retries with backoff on 429/5xx.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash deploy/stats-collector/install.sh # install units + enable timers
|
||||||
|
systemctl --user start stats-collector@fred.service # run one source now
|
||||||
|
# long backfills: detached, survive SSH drops; logs in journalctl --user -u stats-backfill-cbs
|
||||||
|
systemd-run --user --unit=stats-backfill-cbs ~/.local/bin/stats-collector-run.sh cbs --backfill
|
||||||
|
```
|
||||||
|
|
||||||
|
Never touches the `energy` schema or ned-collector.
|
||||||
20
deploy/stats-collector/install.sh
Normal file
20
deploy/stats-collector/install.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the /stats collector on the host as systemd user units (run as stan on stan-ovh):
|
||||||
|
# the runner script, the stats-collector@.service template and one timer per source.
|
||||||
|
# bash deploy/stats-collector/install.sh install + enable every timer
|
||||||
|
# bash deploy/stats-collector/install.sh --no-enable install only
|
||||||
|
set -euo pipefail
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
units="$HOME/.config/systemd/user"
|
||||||
|
install -D -m 755 "$here/stats-collector-run.sh" "$HOME/.local/bin/stats-collector-run.sh"
|
||||||
|
install -D -m 644 "$here/stats-collector@.service" "$units/stats-collector@.service"
|
||||||
|
timers=()
|
||||||
|
for timer in "$here"/stats-*.timer; do
|
||||||
|
install -m 644 "$timer" "$units/$(basename "$timer")"
|
||||||
|
timers+=("$(basename "$timer")")
|
||||||
|
done
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
if [ "${1:-}" != --no-enable ]; then
|
||||||
|
systemctl --user enable --now "${timers[@]}"
|
||||||
|
fi
|
||||||
|
systemctl --user list-timers --no-pager 'stats-*'
|
||||||
11
deploy/stats-collector/stats-bis.timer
Normal file
11
deploy/stats-collector/stats-bis.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: BIS policy rates daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 07:50:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@bis.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-cbs.timer
Normal file
11
deploy/stats-collector/stats-cbs.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: CBS StatLine nightly (re-pulls tables whose Modified changed)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 05:40:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@cbs.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
34
deploy/stats-collector/stats-collector-run.sh
Normal file
34
deploy/stats-collector/stats-collector-run.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# /stats collector runner (systemd: stats-collector@<source>.service, one timer per source).
|
||||||
|
# stats-collector-run.sh <source> [--backfill|--force|--only=…]
|
||||||
|
# Runs scripts/stats-fetch.ts from a detached checkout of origin/main (~/dev/stats-runner), with
|
||||||
|
# DB_URL read fresh from the app container each run (never stored on disk). One lock per source,
|
||||||
|
# so a slow backfill and its timer never overlap; a shared lock guards the checkout update.
|
||||||
|
set -euo pipefail
|
||||||
|
source_name=${1:?source required (cbs, eurostat, fred, bis, …)}
|
||||||
|
case "$source_name" in *[!a-z0-9-]*) echo "bad source name" >&2; exit 2 ;; esac
|
||||||
|
runner="${STATS_RUNNER_DIR:-$HOME/dev/stats-runner}"
|
||||||
|
locks="${XDG_RUNTIME_DIR:-/tmp}/stats-collector"
|
||||||
|
mkdir -p "$locks"
|
||||||
|
exec 9>"$locks/$source_name.lock"
|
||||||
|
if ! flock -w "${STATS_LOCK_WAIT:-3600}" 9; then
|
||||||
|
echo "another $source_name run still holds the lock; giving up" >&2
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
if [ ! -e "$runner/.git" ]; then
|
||||||
|
git -C "$HOME/dev/personal-website" fetch -q origin
|
||||||
|
git -C "$HOME/dev/personal-website" worktree add -q --detach "$runner" origin/main
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
flock -w 600 8
|
||||||
|
cd "$runner"
|
||||||
|
git fetch -q origin
|
||||||
|
git checkout -q --detach origin/main
|
||||||
|
bun install --frozen-lockfile --silent
|
||||||
|
) 8>"$locks/checkout.lock"
|
||||||
|
cd "$runner"
|
||||||
|
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
|
||||||
|
[ -n "$DB_URL" ] || { echo "no DB_URL from app container" >&2; exit 1; }
|
||||||
|
export DB_URL
|
||||||
|
exec nice -n 10 bun scripts/stats-fetch.ts "$@"
|
||||||
10
deploy/stats-collector/stats-collector@.service
Normal file
10
deploy/stats-collector/stats-collector@.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=stanrunge.dev /stats collector: %i (open data → stats.*)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=%h/.local/bin/stats-collector-run.sh %i
|
||||||
|
TimeoutStartSec=3h
|
||||||
|
Nice=10
|
||||||
11
deploy/stats-collector/stats-eurostat.timer
Normal file
11
deploy/stats-collector/stats-eurostat.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: Eurostat weekly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Mon *-*-* 06:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@eurostat.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
deploy/stats-collector/stats-fred.timer
Normal file
11
deploy/stats-collector/stats-fred.timer
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=/stats collector: FRED graph CSV daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 07:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
Unit=stats-collector@fred.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -130,14 +130,14 @@ ALTER TABLE "stats"."series" ADD CONSTRAINT "series_region_code_region_region_co
|
|||||||
-- personal_ro may read. Not registered in meta.tables (no generic table GUI for it).
|
-- personal_ro may read. Not registered in meta.tables (no generic table GUI for it).
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = personal_app) THEN
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
GRANT USAGE ON SCHEMA stats TO personal_app;
|
GRANT USAGE ON SCHEMA stats TO personal_app;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA stats TO personal_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA stats TO personal_app;
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA stats TO personal_app;
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA stats TO personal_app;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA stats GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
ALTER DEFAULT PRIVILEGES IN SCHEMA stats GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA stats GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
ALTER DEFAULT PRIVILEGES IN SCHEMA stats GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
END IF;
|
END IF;
|
||||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = personal_ro) THEN
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
GRANT USAGE ON SCHEMA stats TO personal_ro;
|
GRANT USAGE ON SCHEMA stats TO personal_ro;
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA stats TO personal_ro;
|
GRANT SELECT ON ALL TABLES IN SCHEMA stats TO personal_ro;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA stats GRANT SELECT ON TABLES TO personal_ro;
|
ALTER DEFAULT PRIVILEGES IN SCHEMA stats GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
|||||||
Reference in New Issue
Block a user