Skip to main content

resource_tracker/sentinel/
mod.rs

1//! Sentinel API streaming (Section 9).
2//!
3//! Activation is gated on `SENTINEL_API_TOKEN` being set in the environment.
4//! When the token is absent, `SentinelClient::from_env()` returns `None` and
5//! no HTTP connections are ever made (T-STR-01).
6
7pub mod run;
8pub mod s3;
9pub mod upload;
10
11pub use run::{RunContext, close_run, start_run};
12pub use upload::{BatchUploader, samples_to_csv};
13
14use std::time::Duration;
15use ureq::config::Config as UreqConfig;
16
17/// Default Sentinel API base URL.  Override with `SENTINEL_API_URL`.
18const DEFAULT_API_BASE: &str = "https://api.sentinel.sparecores.net";
19
20/// Per-phase timeouts for Sentinel API calls (no global timeout to avoid ureq DNS helper threads).
21const API_CONNECT_TIMEOUT_SECS: u64 = 10;
22/// Receive-response timeout for Sentinel API calls.
23const API_TIMEOUT_SECS: u64 = 30;
24
25/// Per-phase timeouts for the background S3 upload agent (no global timeout).
26const UPLOAD_CONNECT_TIMEOUT_SECS: u64 = 10;
27const UPLOAD_RECV_RESPONSE_TIMEOUT_SECS: u64 = 30;
28
29// ---------------------------------------------------------------------------
30// SentinelClient
31// ---------------------------------------------------------------------------
32
33/// A configured HTTP client for the Sentinel API.
34///
35/// Constructed only when `SENTINEL_API_TOKEN` is present; every call site
36/// gates on `Option<SentinelClient>` so no HTTP is attempted without a token.
37#[derive(Clone)]
38pub struct SentinelClient {
39    pub token: String,
40    pub api_base: String,
41    pub agent: ureq::Agent,
42}
43
44impl SentinelClient {
45    /// Return `Some(SentinelClient)` when `SENTINEL_API_TOKEN` is set in the
46    /// environment, otherwise `None`.
47    ///
48    /// `SENTINEL_API_URL` overrides the default API base URL.
49    pub fn from_env() -> Option<Self> {
50        let token = std::env::var("SENTINEL_API_TOKEN").ok()?;
51        if token.is_empty() {
52            return None;
53        }
54        let api_base =
55            std::env::var("SENTINEL_API_URL").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
56
57        let agent = UreqConfig::builder()
58            .timeout_connect(Some(Duration::from_secs(API_CONNECT_TIMEOUT_SECS)))
59            .timeout_recv_response(Some(Duration::from_secs(API_TIMEOUT_SECS)))
60            .build()
61            .new_agent();
62
63        Some(Self {
64            token,
65            api_base,
66            agent,
67        })
68    }
69
70    /// HTTP agent for the background S3 upload loop.
71    ///
72    /// Like [`Self::from_env`]'s agent, this uses per-phase timeouts only (no
73    /// `timeout_global`) so ureq's DNS resolver stays synchronous and does not
74    /// spawn a helper thread per lookup. Upload-specific bounds differ from the
75    /// API agent's because S3 PUTs can transfer larger payloads.
76    pub fn new_upload_agent() -> ureq::Agent {
77        UreqConfig::builder()
78            .timeout_connect(Some(Duration::from_secs(UPLOAD_CONNECT_TIMEOUT_SECS)))
79            .timeout_recv_response(Some(Duration::from_secs(UPLOAD_RECV_RESPONSE_TIMEOUT_SECS)))
80            .build()
81            .new_agent()
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Unit tests
87// ---------------------------------------------------------------------------
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::sync::Mutex;
93
94    /// Process env is global; these tests mutate it and must not run concurrently.
95    static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
96
97    // T-STR-01: Without SENTINEL_API_TOKEN, no HTTP connection is made.
98    //
99    // The guard is `SentinelClient::from_env()` returning `None` when the token
100    // is absent.  Every HTTP call site in main.rs is gated on `Option<SentinelClient>`,
101    // so None here provably prevents all HTTP connections.
102    #[test]
103    fn test_no_token_returns_none() {
104        let _lock = ENV_TEST_LOCK.lock().unwrap();
105        // SAFETY: single-threaded test; no concurrent env access.
106        unsafe {
107            std::env::remove_var("SENTINEL_API_TOKEN");
108        }
109        assert!(
110            SentinelClient::from_env().is_none(),
111            "expected None when SENTINEL_API_TOKEN is unset"
112        );
113    }
114
115    #[test]
116    fn test_empty_token_returns_none() {
117        let _lock = ENV_TEST_LOCK.lock().unwrap();
118        // SAFETY: single-threaded test; no concurrent env access.
119        unsafe {
120            std::env::set_var("SENTINEL_API_TOKEN", "");
121        }
122        let result = SentinelClient::from_env();
123        unsafe {
124            std::env::remove_var("SENTINEL_API_TOKEN");
125        }
126        assert!(
127            result.is_none(),
128            "expected None when SENTINEL_API_TOKEN is empty string"
129        );
130    }
131
132    // T-STR-07: a non-empty token returns Some with the correct token and default URL.
133    #[test]
134    fn test_valid_token_returns_some_with_defaults() {
135        let _lock = ENV_TEST_LOCK.lock().unwrap();
136        // SAFETY: single-threaded test; no concurrent env access.
137        unsafe {
138            std::env::set_var("SENTINEL_API_TOKEN", "my-test-token");
139        }
140        unsafe {
141            std::env::remove_var("SENTINEL_API_URL");
142        }
143        let result = SentinelClient::from_env();
144        unsafe {
145            std::env::remove_var("SENTINEL_API_TOKEN");
146        }
147        let client = result.expect("expected Some when SENTINEL_API_TOKEN is non-empty");
148        assert_eq!(client.token, "my-test-token");
149        assert_eq!(client.api_base, DEFAULT_API_BASE);
150    }
151
152    // T-STR-08: SENTINEL_API_URL overrides the default API base URL.
153    #[test]
154    fn test_api_url_env_override() {
155        let _lock = ENV_TEST_LOCK.lock().unwrap();
156        // SAFETY: single-threaded test; no concurrent env access.
157        unsafe {
158            std::env::set_var("SENTINEL_API_TOKEN", "tok");
159        }
160        unsafe {
161            std::env::set_var("SENTINEL_API_URL", "http://localhost:9999");
162        }
163        let result = SentinelClient::from_env();
164        unsafe {
165            std::env::remove_var("SENTINEL_API_TOKEN");
166        }
167        unsafe {
168            std::env::remove_var("SENTINEL_API_URL");
169        }
170        let client = result.expect("expected Some when token is set");
171        assert_eq!(client.api_base, "http://localhost:9999");
172    }
173}