Phase 4 of docs/RUST_REWRITE.md. Since 2026-10-18 ping-collector (rust/) collects, forecasts, notifies (ntfy + Matrix) and serves the website/API; nothing in production ran the Python service code any more. Removed (~17k lines): the FastAPI app (web_api, schemas, health_check, metrics, postgres_history, forecast_history, thaiwater), the Python RID collector and CLI (water_scraper_v3, main, run.py), the notifier and Matrix alerting (notify, alerting), the multi-backend adapters (SQLite, MySQL, InfluxDB, VictoriaMetrics) with demo databases, models/validators/exceptions/ logging/rate limiter, ml/skill.py (served by Rust now), the migration tooling (api_parity, collector_parity, e2e_notify, install_collector.sh, gen_notify_cases.py, spikes/rust-forecast), Docker/compose/Grafana files, sql/, the release workflow and the docs for all of that. Their tests went with them. Kept in Python: src/ml (training, evaluation harness, export; predict.py as the reference the Rust forecaster is tested against), the HII and RID-dam clients/stores for the archive backfills (their live collectors removed), the backfill scripts, retrain.sh + timer, forecast_parity.py. - PostgreSQL only: src/config.py is database_url() from POSTGRES_* / .env; the stores lost their sqlite/mysql branches (and the 2026-08 column migration). The database tests run against a throwaway schema from TEST_DATABASE_URL (tests/conftest.py pg_url, skipped without it), as the Rust tests do; CI gets a PostgreSQL service. 100 tests (66 without a DB). - The notifier settings moved from src/notify.py into src/ml/export.py; flood_config.json regenerates byte-identical. - tests/test_dashboard.py checks every path the dashboard fetches against ping-collector's router. - Dependencies: fastapi, uvicorn, pydantic, influxdb, pymysql, psutil, schedule, sphinx, pyinstaller, mypy, pytest-asyncio dropped; python-dotenv 1.1.1 explicit (it came in through uvicorn[standard]). - Formatting contract extended to scripts/ and tests/ (reformatted once). - scripts/install.sh is the one installer (fresh host or update, in place in a checkout without touching its ownership): binary, units, uv sync, .env defaults, removes the retired water-monitor.service and any old --alert-check cron job, dry run + forecast check, restart, readiness wait. - Docs, CLAUDE.md, CONTRIBUTING.md, .env.example, Makefile, unit comments and Rust doc comments updated to the Rust service.
203 lines
9.5 KiB
Bash
Executable File
203 lines
9.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Install or update the Ping River Monitor on a systemd host.
|
|
#
|
|
# sudo bash scripts/install.sh
|
|
#
|
|
# What runs:
|
|
# ping-collector.service the service (rust/collector): RID/HII/dam/rain
|
|
# collection, forecast, ntfy + Matrix
|
|
# notifications, website + API on WEB_LISTEN
|
|
# water-monitor-retrain.timer monthly model retrain (Python, scripts/retrain.sh)
|
|
#
|
|
# Re-run after every update (git pull + build). Steps, all idempotent:
|
|
# 1. system user/group (water-monitor)
|
|
# 2. code: run from a checkout other than APP_DIR, it is rsynced there (the
|
|
# trained models, .env, stations.json and bin/ are kept) and handed to
|
|
# the service user. Production runs in place (APP_DIR is the checkout):
|
|
# then nothing is copied and no ownership changes.
|
|
# 3. the binary: BIN=/path/to/ping-collector (e.g. the CI artifact
|
|
# "ping-collector-linux-x86_64") or rust/target/release/ping-collector,
|
|
# built beforehand as a normal user:
|
|
# cargo build --release --locked --manifest-path rust/Cargo.toml
|
|
# (Rust >= 1.88 from rustup, plus pkg-config and libssl-dev)
|
|
# 4. the Python training environment (.venv, `uv sync --frozen`): in place
|
|
# as the user running sudo (who pulled and built), otherwise as root;
|
|
# SKIP_UV=1 skips it
|
|
# 5. .env: created from .env.example on a fresh install (edit, then re-run);
|
|
# WEB_LISTEN and FORECAST_IN_COLLECTOR default to the production values
|
|
# when absent
|
|
# 6. systemd units; the retired Python web app (water-monitor.service) and
|
|
# the old `run.py --alert-check` Matrix cron job are removed
|
|
# 7. checks (SKIP_CHECKS=1 skips them): a dry run of every source against
|
|
# the database, and the forecast compared with the Python reference on
|
|
# the live data (scripts/forecast_parity.py)
|
|
# 8. (re)start, then wait for the website to answer
|
|
set -euo pipefail
|
|
|
|
APP_DIR="${APP_DIR:-/opt/thailand-water-monitor}"
|
|
SERVICE_USER="${SERVICE_USER:-water-monitor}" # as in the units
|
|
SERVICE_GROUP="${SERVICE_GROUP:-${SERVICE_USER}}"
|
|
ENV_FILE="${APP_DIR}/.env"
|
|
PYTHON="${APP_DIR}/.venv/bin/python"
|
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
RETRAIN="water-monitor-retrain"
|
|
|
|
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
[ "$(id -u)" -eq 0 ] || die "run as root (sudo)"
|
|
|
|
set_default() { # key value: append when the key is not set at all
|
|
grep -q "^$1=" "$ENV_FILE" || printf '%s=%s\n' "$1" "$2" >>"$ENV_FILE"
|
|
}
|
|
|
|
# A command the way the services run it: same user, working directory and
|
|
# EnvironmentFile parsing (systemd's).
|
|
as_service() {
|
|
if systemd-run --version >/dev/null 2>&1; then
|
|
systemd-run --quiet --pipe --wait --collect \
|
|
-p User="$SERVICE_USER" -p Group="$SERVICE_GROUP" \
|
|
-p WorkingDirectory="$APP_DIR" -p EnvironmentFile="$ENV_FILE" "$@"
|
|
else
|
|
(cd "$APP_DIR" && runuser -u "$SERVICE_USER" -- "$@")
|
|
fi
|
|
}
|
|
|
|
wait_http() { # url seconds
|
|
local i
|
|
for i in $(seq "$2"); do
|
|
curl -fs -o /dev/null --max-time 5 "$1" 2>/dev/null && return 0
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# 1. user ---------------------------------------------------------------------
|
|
getent group "$SERVICE_GROUP" >/dev/null || groupadd --system "$SERVICE_GROUP"
|
|
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
|
log "Creating system user $SERVICE_USER"
|
|
useradd --system --no-create-home --shell /usr/sbin/nologin \
|
|
--gid "$SERVICE_GROUP" "$SERVICE_USER"
|
|
fi
|
|
|
|
# 2. code ---------------------------------------------------------------------
|
|
in_place=0
|
|
[ -d "$APP_DIR" ] && [ "$(cd "$APP_DIR" && pwd -P)" = "$(cd "$REPO_DIR" && pwd -P)" ] && in_place=1
|
|
if [ "$in_place" = 0 ]; then
|
|
command -v rsync >/dev/null || die "rsync is needed to copy $REPO_DIR to $APP_DIR"
|
|
log "Copying $REPO_DIR to $APP_DIR"
|
|
mkdir -p "$APP_DIR"
|
|
rsync -a --delete \
|
|
--exclude '.git' --exclude '.venv' --exclude '__pycache__' --exclude '/rust/target' \
|
|
--exclude '/.env' --exclude '/stations.json' --exclude '/bin' \
|
|
--exclude '/collector-status.json' --exclude '/crontab-*.bak' \
|
|
--exclude '/models/flood_*' --exclude '/models/metrics.json' \
|
|
--exclude '/models/cache' --exclude '/models/.staging' --exclude '/models/.previous' \
|
|
"$REPO_DIR/" "$APP_DIR/"
|
|
fi
|
|
|
|
# 3. binary -------------------------------------------------------------------
|
|
BIN="${BIN:-${REPO_DIR}/rust/target/release/ping-collector}"
|
|
[ -x "$BIN" ] || die "no binary at $BIN: build it (see the header) or pass BIN=/path/to/ping-collector"
|
|
log "Installing $BIN -> ${APP_DIR}/bin/ping-collector ($("$BIN" --version))"
|
|
install -D -m 0755 -o root -g root "$BIN" "${APP_DIR}/bin/ping-collector"
|
|
|
|
# 4. Python training environment ------------------------------------------------
|
|
if [ "${SKIP_UV:-0}" != 1 ]; then
|
|
uv_user=root # copy mode: handed to the service user below
|
|
[ "$in_place" = 1 ] && uv_user="${SUDO_USER:-root}"
|
|
log "Syncing the Python training environment (${APP_DIR}/.venv) as $uv_user"
|
|
if [ "$uv_user" = root ]; then
|
|
(cd "$APP_DIR" && uv sync --python 3.11 --frozen) ||
|
|
warn "uv sync failed: the monthly retrain needs ${APP_DIR}/.venv (uv sync --python 3.11 --frozen)"
|
|
else
|
|
runuser -u "$uv_user" -- bash -lc "cd '$APP_DIR' && uv sync --python 3.11 --frozen" ||
|
|
warn "uv sync as $uv_user failed: run 'uv sync --python 3.11 --frozen' in $APP_DIR as the owner of .venv"
|
|
fi
|
|
fi
|
|
|
|
# 5. .env ---------------------------------------------------------------------
|
|
fresh_env=0
|
|
if [ ! -f "$ENV_FILE" ]; then
|
|
cp "${APP_DIR}/.env.example" "$ENV_FILE"
|
|
chmod 0600 "$ENV_FILE"
|
|
fresh_env=1
|
|
fi
|
|
set_default WEB_LISTEN 0.0.0.0:8000
|
|
set_default FORECAST_IN_COLLECTOR true
|
|
|
|
if [ "$in_place" = 0 ]; then
|
|
# a copy belongs to the service (it writes models/, stations.json and the
|
|
# status file); an in-place checkout keeps its owner (git pull, builds)
|
|
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$APP_DIR"
|
|
chown root:root "${APP_DIR}/bin/ping-collector"
|
|
chmod 0750 "$APP_DIR"
|
|
fi
|
|
|
|
# 6. units --------------------------------------------------------------------
|
|
log "Installing systemd units"
|
|
install -m 0644 "${APP_DIR}/scripts/ping-collector.service" /etc/systemd/system/ping-collector.service
|
|
install -m 0644 "${APP_DIR}/scripts/${RETRAIN}.service" "/etc/systemd/system/${RETRAIN}.service"
|
|
install -m 0644 "${APP_DIR}/scripts/${RETRAIN}.timer" "/etc/systemd/system/${RETRAIN}.timer"
|
|
if [ -f /etc/systemd/system/water-monitor.service ]; then
|
|
log "Removing the retired Python web app (water-monitor.service)"
|
|
systemctl disable --now water-monitor.service 2>/dev/null || true
|
|
rm -f /etc/systemd/system/water-monitor.service
|
|
fi
|
|
systemctl daemon-reload
|
|
systemctl enable ping-collector.service
|
|
systemctl enable --now "${RETRAIN}.timer"
|
|
|
|
# The old Matrix cron job (`run.py --alert-check`, repeating an alert every
|
|
# 15 minutes while a level stayed high): ping-collector sends Matrix now.
|
|
for user in root "$SERVICE_USER"; do
|
|
crontab -l -u "$user" 2>/dev/null | grep -v '^\s*#' | grep -q -- '--alert-check' || continue
|
|
backup="${APP_DIR}/crontab-${user}.$(date +%Y%m%d%H%M%S).bak"
|
|
crontab -l -u "$user" >"$backup"
|
|
sed '/--alert-check/ s|^\([^#]\)|# retired: Matrix is sent by ping-collector # \1|' "$backup" |
|
|
crontab -u "$user" -
|
|
log "Commented out the old --alert-check cron job of $user (backup: $backup)"
|
|
done
|
|
if grep -rlqs -- '--alert-check' /etc/cron.d /etc/crontab 2>/dev/null; then
|
|
warn "an --alert-check job is still in $(grep -rls -- '--alert-check' /etc/cron.d /etc/crontab | tr '\n' ' '): remove it"
|
|
fi
|
|
|
|
if [ "$fresh_env" = 1 ]; then
|
|
warn "created $ENV_FILE from .env.example: set the database (POSTGRES_*), NTFY_* and MATRIX_*, then re-run"
|
|
exit 0
|
|
fi
|
|
|
|
# 7. checks -------------------------------------------------------------------
|
|
if [ "${SKIP_CHECKS:-0}" != 1 ]; then
|
|
log "Dry run of every source against the configured database (writes nothing)"
|
|
as_service "${APP_DIR}/bin/ping-collector" once --dry-run
|
|
if compgen -G "${APP_DIR}/models/flood_*.joblib" >/dev/null && [ -x "$PYTHON" ]; then
|
|
log "Exporting trained models for the forecaster (models/flood_*.json.gz)"
|
|
as_service "$PYTHON" -m src.ml.export
|
|
log "Forecast check: ping-collector against the Python reference, same data, moment and rain"
|
|
as_service "$PYTHON" scripts/forecast_parity.py ||
|
|
warn "the forecast check found differences (see above); the service runs regardless"
|
|
else
|
|
warn "no trained models or no ${PYTHON}: forecast check skipped (the forecast falls back to the heuristic)"
|
|
fi
|
|
fi
|
|
|
|
# 8. (re)start ----------------------------------------------------------------
|
|
systemctl restart ping-collector.service
|
|
listen="$(grep '^WEB_LISTEN=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"'"'"' ')"
|
|
if [ -n "$listen" ]; then
|
|
port="${listen##*:}"
|
|
# readiness: a route that answers 200 whatever the data (/health may be 503)
|
|
if wait_http "http://127.0.0.1:${port}/api/notifications" 60; then
|
|
log "Website and API answer on :${port} ($(curl -s --max-time 5 "http://127.0.0.1:${port}/scraping/status" | grep -o '"collector":"[^"]*"'))"
|
|
else
|
|
warn "no answer on port ${port} yet: journalctl -u ping-collector"
|
|
fi
|
|
fi
|
|
sleep 3
|
|
systemctl --no-pager --lines=0 status ping-collector.service || true
|
|
journalctl -u ping-collector -n 25 --no-pager || true
|
|
echo
|
|
log "Done. Logs: journalctl -u ping-collector -f; retrain: systemctl list-timers ${RETRAIN}.timer"
|