posemesh_compute_node/
config.rs

1use anyhow::{bail, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::env;
4use url::Url;
5
6const DEFAULT_DMS_BASE_URL: &str = "https://dms.auki.network/v1";
7const DEFAULT_DDS_BASE_URL: &str = "https://dds.auki.network";
8const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 60;
9const DEFAULT_REGISTER_INTERVAL_SECS: u64 = 120;
10const DEFAULT_REGISTER_MAX_RETRY: i32 = -1;
11
12/// Log output format.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum LogFormat {
16    #[default]
17    Json,
18    Text,
19}
20
21/// Node configuration loaded from environment (SPECS ยง8 Configuration).
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct NodeConfig {
24    // Core settings (defaults available).
25    pub dms_base_url: Url,
26    pub node_version: String,
27    pub request_timeout_secs: u64,
28
29    // Auth: either static node identity or SIWE via DDS
30    pub dds_base_url: Option<Url>,
31    pub node_url: Option<Url>,
32    pub reg_secret: Option<String>,
33    pub secp256k1_privhex: Option<String>,
34
35    // Optional
36    pub heartbeat_jitter_ms: u64,
37    pub poll_backoff_ms_min: u64,
38    pub poll_backoff_ms_max: u64,
39    pub token_safety_ratio: f32,
40    pub token_reauth_max_retries: u32,
41    pub token_reauth_jitter_ms: u64,
42    pub register_interval_secs: Option<u64>,
43    pub register_max_retry: Option<i32>,
44    pub max_concurrency: u32,
45    pub log_format: LogFormat,
46    pub enable_noop: bool,
47    pub noop_sleep_secs: u64,
48}
49
50impl NodeConfig {
51    /// Load configuration from environment variables.
52    pub fn from_env() -> Result<Self> {
53        // Core settings (defaults when unset).
54        let dms_base_url = parse_url_default("DMS_BASE_URL", DEFAULT_DMS_BASE_URL)?;
55        let request_timeout_secs =
56            parse_u64_default("REQUEST_TIMEOUT_SECS", DEFAULT_REQUEST_TIMEOUT_SECS)?;
57        let node_version = env::var("NODE_VERSION")
58            .ok()
59            .filter(|s| !s.trim().is_empty())
60            .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
61
62        // Auth options
63        let dds_base_url = parse_url_default("DDS_BASE_URL", DEFAULT_DDS_BASE_URL)?;
64        let node_url = Url::parse(
65            &env::var("NODE_URL")
66                .with_context(|| "NODE_URL required for DDS SIWE authentication")?,
67        )
68        .with_context(|| "invalid URL in NODE_URL")?;
69        let reg_secret = env::var("REG_SECRET")
70            .with_context(|| "REG_SECRET required for DDS SIWE authentication")?
71            .trim()
72            .to_string();
73        if reg_secret.is_empty() {
74            bail!("REG_SECRET required for DDS SIWE authentication");
75        }
76        let secp256k1_privhex = env::var("SECP256K1_PRIVHEX")
77            .with_context(|| "SECP256K1_PRIVHEX required for DDS SIWE authentication")?
78            .trim()
79            .to_string();
80        if secp256k1_privhex.is_empty() {
81            bail!("SECP256K1_PRIVHEX required for DDS SIWE authentication");
82        }
83
84        // Optional
85        let heartbeat_jitter_ms = parse_u64_opt("HEARTBEAT_JITTER_MS", 250)?;
86        let poll_backoff_ms_min = parse_u64_opt("POLL_BACKOFF_MS_MIN", 1000)?;
87        let poll_backoff_ms_max = parse_u64_opt("POLL_BACKOFF_MS_MAX", 30000)?;
88        let token_safety_ratio = parse_f32_opt("TOKEN_SAFETY_RATIO", 0.75)?;
89        let token_reauth_max_retries = parse_u32_opt("TOKEN_REAUTH_MAX_RETRIES", 3)?;
90        let token_reauth_jitter_ms = parse_u64_opt("TOKEN_REAUTH_JITTER_MS", 500)?;
91        let register_interval_secs = Some(parse_u64_default(
92            "REGISTER_INTERVAL_SECS",
93            DEFAULT_REGISTER_INTERVAL_SECS,
94        )?);
95        let register_max_retry = Some(parse_i32_default(
96            "REGISTER_MAX_RETRY",
97            DEFAULT_REGISTER_MAX_RETRY,
98        )?);
99        let max_concurrency = parse_u32_opt("MAX_CONCURRENCY", 1)?;
100        let log_format = parse_log_format("LOG_FORMAT").unwrap_or_default();
101        let enable_noop = parse_bool_opt("ENABLE_NOOP", false)?;
102        let noop_sleep_secs = parse_u64_opt("NOOP_SLEEP_SECS", 5)?;
103
104        Ok(Self {
105            dms_base_url,
106            node_version,
107            request_timeout_secs,
108            dds_base_url: Some(dds_base_url),
109            node_url: Some(node_url),
110            reg_secret: Some(reg_secret),
111            secp256k1_privhex: Some(secp256k1_privhex),
112            heartbeat_jitter_ms,
113            poll_backoff_ms_min,
114            poll_backoff_ms_max,
115            token_safety_ratio,
116            token_reauth_max_retries,
117            token_reauth_jitter_ms,
118            register_interval_secs,
119            register_max_retry,
120            max_concurrency,
121            log_format,
122            enable_noop,
123            noop_sleep_secs,
124        })
125    }
126}
127
128fn env_var_trimmed(key: &str) -> Option<String> {
129    env::var(key).ok().and_then(|value| {
130        let trimmed = value.trim();
131        if trimmed.is_empty() {
132            None
133        } else {
134            Some(trimmed.to_string())
135        }
136    })
137}
138
139fn parse_url_default(key: &str, default: &str) -> Result<Url> {
140    let raw = env_var_trimmed(key).unwrap_or_else(|| default.to_string());
141    Url::parse(&raw).with_context(|| format!("invalid URL in {key}"))
142}
143
144fn parse_u64_default(key: &str, default: u64) -> Result<u64> {
145    match env_var_trimmed(key) {
146        Some(value) => value
147            .parse()
148            .with_context(|| format!("invalid integer in {key}")),
149        None => Ok(default),
150    }
151}
152
153fn parse_u64_opt(key: &str, default: u64) -> Result<u64> {
154    match env::var(key) {
155        Ok(v) => v
156            .parse()
157            .with_context(|| format!("invalid integer in {key}")),
158        Err(_) => Ok(default),
159    }
160}
161
162fn parse_u32_opt(key: &str, default: u32) -> Result<u32> {
163    match env::var(key) {
164        Ok(v) => v
165            .parse()
166            .with_context(|| format!("invalid integer in {key}")),
167        Err(_) => Ok(default),
168    }
169}
170
171fn parse_i32_default(key: &str, default: i32) -> Result<i32> {
172    match env_var_trimmed(key) {
173        Some(value) => {
174            let parsed: i32 = value
175                .parse()
176                .with_context(|| format!("invalid integer in {key}"))?;
177            if parsed < -1 {
178                bail!("{key} must be -1 or a non-negative integer, got {parsed}");
179            }
180            Ok(parsed)
181        }
182        None => Ok(default),
183    }
184}
185
186fn parse_f32_opt(key: &str, default: f32) -> Result<f32> {
187    match env::var(key) {
188        Ok(v) => v.parse().with_context(|| format!("invalid float in {key}")),
189        Err(_) => Ok(default),
190    }
191}
192
193fn parse_bool_opt(key: &str, default: bool) -> Result<bool> {
194    match env::var(key) {
195        Ok(v) => v
196            .parse::<bool>()
197            .with_context(|| format!("invalid bool in {key}; expected true/false")),
198        Err(_) => Ok(default),
199    }
200}
201
202fn parse_log_format(key: &str) -> Option<LogFormat> {
203    match env::var(key).ok()?.to_lowercase().as_str() {
204        "json" => Some(LogFormat::Json),
205        "text" => Some(LogFormat::Text),
206        _ => None,
207    }
208}