Files
Northern-Thailand-Ping-Rive…/rust/collector/src/config.rs
T
grabowski 4e619aa2ae
CI / Format & lint (push) Successful in 19s
CI / Test suite (push) Successful in 47s
Docs / Validate documentation (push) Successful in 19s
Rust / fmt, clippy, test, build (push) Successful in 7m35s
Security / Dependency vulnerabilities (push) Successful in 1m37s
Security / Static analysis (push) Successful in 17s
Security / License report (push) Successful in 1m30s
feat(rust): ping-collector, phase 1 of the Rust rewrite
rust/collector replaces the collection half of the Python web app: RID hourly
gauges (with the late-report semantics: store everything, retry every 10 min
while RID is behind, re-fetch yesterday's holes, startup gap fill), HII rain +
water level, RID dams (today + yesterday) and Open-Meteo rain. Same tables and
upsert semantics, one UNNEST statement per table, Bangkok wall-clock time
regardless of the server time zone.

Python side: COLLECTION_ENABLED=false makes the API leader stop collecting and
instead re-forecast + notify when the newest RID reading in the database
advances (polled every FORECAST_POLL_SECONDS) and once per clock hour;
/scraping/status then serves the collector's status file; /scrape/trigger
answers 409.

Verified: 20 Rust tests (parsers, gap/schedule logic, database tests against
PostgreSQL); live parity run of both collectors into two empty databases:
all 9 tables row-for-row identical (scripts/collector_parity.py); the Python
API served health, measurements, stats, status and model forecasts from a
database filled only by ping-collector. 177 Python tests pass.

reqwest uses native-tls: app.rid.go.th offers only TLS 1.2 CBC suites, which
rustls refuses.

Deploy/cutover/rollback: scripts/install_collector.sh, scripts/ping-collector.service;
CI: .gitea/workflows/rust.yml (fmt, clippy, tests with a PostgreSQL service,
release artifact).
2026-10-17 22:20:02 +07:00

176 lines
6.3 KiB
Rust

//! Configuration from the same environment / `.env` file as the Python app,
//! so both services can share `/opt/thailand-water-monitor/.env`.
use anyhow::{Context, Result, bail};
use std::env;
use std::path::PathBuf;
pub const RID_API_URL: &str =
"https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx";
pub const HII_API_BASE: &str = "https://api-v3.thaiwater.net/api/v1/thaiwater30/public";
pub const RID_DAMS_URL: &str = "https://app.rid.go.th/reservoir/api/dams";
pub const OPENMETEO_URL: &str = "https://api.open-meteo.com/v1/forecast";
/// The RID endpoint serves the same JSON to any client; keep the UA the
/// Python collector has used for years.
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub rid_api_url: String,
pub hii_api_base: String,
pub dams_url: String,
pub openmeteo_url: String,
pub hii_enabled: bool,
pub dams_enabled: bool,
pub rain_enabled: bool,
pub hii_basin_code: i64,
/// RID is polled this many minutes past each hour.
pub poll_offset_minutes: u32,
/// While the current hour is not published yet, retry this often.
pub retry_minutes: u32,
/// Days of RID holes re-fetched at startup (0 = off).
pub gap_fill_days_on_start: u32,
pub station_config_path: PathBuf,
/// Status JSON read by the Python API's /scraping/status.
pub status_file: Option<PathBuf>,
}
fn var(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn flag(name: &str, default: bool) -> bool {
var(name).map_or(default, |v| {
matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")
})
}
fn number<T: std::str::FromStr>(name: &str, default: T) -> Result<T>
where
T::Err: std::fmt::Display,
{
match var(name) {
None => Ok(default),
Some(v) => v.parse().map_err(|e| anyhow::anyhow!("{name}={v:?}: {e}")),
}
}
/// Percent-encode everything outside RFC 3986 "unreserved" (as Python's
/// `urllib.parse.quote(password, safe="")`).
fn encode_userinfo(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// SQLAlchemy URLs carry a driver suffix (`postgresql+psycopg2://`) that
/// sqlx does not understand.
pub fn normalize_database_url(url: &str) -> String {
match url.split_once("://") {
Some((scheme, rest))
if scheme.starts_with("postgresql") || scheme.starts_with("postgres") =>
{
format!("postgres://{rest}")
}
_ => url.to_string(),
}
}
impl Config {
pub fn from_env() -> Result<Self> {
let database_url = match var("POSTGRES_CONNECTION_STRING") {
Some(url) => normalize_database_url(&url),
None => {
let password = var("POSTGRES_PASSWORD")
.context("POSTGRES_CONNECTION_STRING or POSTGRES_PASSWORD must be set")?;
format!(
"postgres://{}:{}@{}:{}/{}",
encode_userinfo(&var("POSTGRES_USER").unwrap_or_else(|| "postgres".into())),
encode_userinfo(&password),
var("POSTGRES_HOST").unwrap_or_else(|| "localhost".into()),
var("POSTGRES_PORT").unwrap_or_else(|| "5432".into()),
var("POSTGRES_DB").unwrap_or_else(|| "water_monitoring".into()),
)
}
};
if let Some(db_type) = var("DB_TYPE")
&& db_type != "postgresql"
{
bail!("ping-collector supports PostgreSQL only (DB_TYPE={db_type})");
}
let cfg = Config {
database_url,
rid_api_url: var("RID_API_URL").unwrap_or_else(|| RID_API_URL.into()),
hii_api_base: var("HII_API_BASE").unwrap_or_else(|| HII_API_BASE.into()),
dams_url: var("RID_DAMS_URL").unwrap_or_else(|| RID_DAMS_URL.into()),
openmeteo_url: var("OPENMETEO_URL").unwrap_or_else(|| OPENMETEO_URL.into()),
hii_enabled: flag("ENABLE_HII_COLLECTION", true),
dams_enabled: flag("ENABLE_RESERVOIR_COLLECTION", true),
rain_enabled: flag("ENABLE_RAIN_COLLECTION", true),
hii_basin_code: number("HII_BASIN_CODE", 6)?,
poll_offset_minutes: number("RID_POLL_OFFSET_MINUTES", 5)?,
retry_minutes: number("RID_RETRY_MINUTES", 10)?,
gap_fill_days_on_start: number("GAP_FILL_DAYS_ON_START", 14)?,
station_config_path: var("STATION_CONFIG_PATH")
.unwrap_or_else(|| "stations.json".into())
.into(),
status_file: var("SCRAPING_STATUS_FILE").map(PathBuf::from),
};
if cfg.poll_offset_minutes >= 60 {
bail!("RID_POLL_OFFSET_MINUTES must be between 0 and 59");
}
if cfg.retry_minutes == 0 {
bail!("RID_RETRY_MINUTES must be positive");
}
Ok(cfg)
}
/// The URL with the password masked, for logs.
pub fn redacted_database_url(&self) -> String {
match (self.database_url.find("://"), self.database_url.rfind('@')) {
(Some(s), Some(at)) if at > s => {
let creds = &self.database_url[s + 3..at];
let user = creds.split(':').next().unwrap_or("");
format!(
"{}{user}:***{}",
&self.database_url[..s + 3],
&self.database_url[at..]
)
}
_ => self.database_url.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlalchemy_urls_are_normalized() {
assert_eq!(
normalize_database_url("postgresql+psycopg2://u:p@h:5432/db"),
"postgres://u:p@h:5432/db"
);
assert_eq!(
normalize_database_url("postgresql://u@h/db"),
"postgres://u@h/db"
);
}
#[test]
fn passwords_are_percent_encoded() {
assert_eq!(encode_userinfo("p@ss:w/rd"), "p%40ss%3Aw%2Frd");
}
}