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/// HTTP timeout for Sentinel API calls (not S3 uploads, which are separate).
21const API_TIMEOUT_SECS: u64 = 30;
22
23// ---------------------------------------------------------------------------
24// SentinelClient
25// ---------------------------------------------------------------------------
26
27/// A configured HTTP client for the Sentinel API.
28///
29/// Constructed only when `SENTINEL_API_TOKEN` is present; every call site
30/// gates on `Option<SentinelClient>` so no HTTP is attempted without a token.
31#[derive(Clone)]
32pub struct SentinelClient {
33    pub token: String,
34    pub api_base: String,
35    pub agent: ureq::Agent,
36}
37
38impl SentinelClient {
39    /// Return `Some(SentinelClient)` when `SENTINEL_API_TOKEN` is set in the
40    /// environment, otherwise `None`.
41    ///
42    /// `SENTINEL_API_URL` overrides the default API base URL.
43    pub fn from_env() -> Option<Self> {
44        let token = std::env::var("SENTINEL_API_TOKEN").ok()?;
45        if token.is_empty() {
46            return None;
47        }
48        let api_base =
49            std::env::var("SENTINEL_API_URL").unwrap_or_else(|_| DEFAULT_API_BASE.to_string());
50
51        let agent = UreqConfig::builder()
52            .timeout_global(Some(Duration::from_secs(API_TIMEOUT_SECS)))
53            .build()
54            .new_agent();
55
56        Some(Self {
57            token,
58            api_base,
59            agent,
60        })
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Unit tests
66// ---------------------------------------------------------------------------
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    // T-STR-01: Without SENTINEL_API_TOKEN, no HTTP connection is made.
73    //
74    // The guard is `SentinelClient::from_env()` returning `None` when the token
75    // is absent.  Every HTTP call site in main.rs is gated on `Option<SentinelClient>`,
76    // so None here provably prevents all HTTP connections.
77    #[test]
78    fn test_no_token_returns_none() {
79        // SAFETY: single-threaded test; no concurrent env access.
80        unsafe {
81            std::env::remove_var("SENTINEL_API_TOKEN");
82        }
83        assert!(
84            SentinelClient::from_env().is_none(),
85            "expected None when SENTINEL_API_TOKEN is unset"
86        );
87    }
88
89    #[test]
90    fn test_empty_token_returns_none() {
91        // SAFETY: single-threaded test; no concurrent env access.
92        unsafe {
93            std::env::set_var("SENTINEL_API_TOKEN", "");
94        }
95        let result = SentinelClient::from_env();
96        unsafe {
97            std::env::remove_var("SENTINEL_API_TOKEN");
98        }
99        assert!(
100            result.is_none(),
101            "expected None when SENTINEL_API_TOKEN is empty string"
102        );
103    }
104
105    // T-STR-07: a non-empty token returns Some with the correct token and default URL.
106    #[test]
107    fn test_valid_token_returns_some_with_defaults() {
108        // SAFETY: single-threaded test; no concurrent env access.
109        unsafe {
110            std::env::set_var("SENTINEL_API_TOKEN", "my-test-token");
111        }
112        unsafe {
113            std::env::remove_var("SENTINEL_API_URL");
114        }
115        let result = SentinelClient::from_env();
116        unsafe {
117            std::env::remove_var("SENTINEL_API_TOKEN");
118        }
119        let client = result.expect("expected Some when SENTINEL_API_TOKEN is non-empty");
120        assert_eq!(client.token, "my-test-token");
121        assert_eq!(client.api_base, DEFAULT_API_BASE);
122    }
123
124    // T-STR-08: SENTINEL_API_URL overrides the default API base URL.
125    #[test]
126    fn test_api_url_env_override() {
127        // SAFETY: single-threaded test; no concurrent env access.
128        unsafe {
129            std::env::set_var("SENTINEL_API_TOKEN", "tok");
130        }
131        unsafe {
132            std::env::set_var("SENTINEL_API_URL", "http://localhost:9999");
133        }
134        let result = SentinelClient::from_env();
135        unsafe {
136            std::env::remove_var("SENTINEL_API_TOKEN");
137        }
138        unsafe {
139            std::env::remove_var("SENTINEL_API_URL");
140        }
141        let client = result.expect("expected Some when token is set");
142        assert_eq!(client.api_base, "http://localhost:9999");
143    }
144}