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
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).
176 lines
6.3 KiB
Rust
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");
|
|
}
|
|
}
|