Skip to main content

atrg_core/
env_override.rs

1//! Environment variable overrides for `atrg.toml` configuration.
2//!
3//! Any field in [`Config`] can be overridden at runtime
4//! via an environment variable. The naming convention uses a double-underscore
5//! (`__`) to separate the section from the field:
6//!
7//! | Config field            | Env var                    |
8//! |-------------------------|----------------------------|
9//! | `app.name`              | `ATRG_APP__NAME`           |
10//! | `app.host`              | `ATRG_APP__HOST`           |
11//! | `app.port`              | `ATRG_APP__PORT`           |
12//! | `app.secret_key`        | `ATRG_APP__SECRET_KEY`     |
13//! | `app.environment`       | `ATRG_APP__ENVIRONMENT`    |
14//! | `app.cors_origins`      | `ATRG_APP__CORS_ORIGINS`   |
15//! | `auth.client_id`        | `ATRG_AUTH__CLIENT_ID`     |
16//! | `auth.redirect_uri`     | `ATRG_AUTH__REDIRECT_URI`  |
17//! | `auth.scope`            | `ATRG_AUTH__SCOPE`         |
18//! | `database.url`          | `ATRG_DATABASE__URL`       |
19//!
20//! For `app.cors_origins`, provide a comma-separated list of origins, e.g.
21//! `ATRG_APP__CORS_ORIGINS="http://localhost:5173,https://example.com"`.
22//!
23//! Invalid values (e.g. a non-numeric `ATRG_APP__PORT`) are logged as warnings
24//! and silently ignored — the original config value is preserved.
25
26use crate::config::Config;
27
28/// Apply environment variable overrides to a mutable [`Config`].
29///
30/// Call this after loading `atrg.toml` but before validation, so that env vars
31/// can fix up values for the current deployment environment.
32pub fn apply_env_overrides(config: &mut Config) {
33    // -- [app] ----------------------------------------------------------
34    if let Ok(val) = std::env::var("ATRG_APP__NAME") {
35        tracing::debug!(key = "ATRG_APP__NAME", "applying env override");
36        config.app.name = val;
37    }
38
39    if let Ok(val) = std::env::var("ATRG_APP__HOST") {
40        tracing::debug!(key = "ATRG_APP__HOST", "applying env override");
41        config.app.host = val;
42    }
43
44    if let Ok(val) = std::env::var("ATRG_APP__PORT") {
45        match val.parse::<u16>() {
46            Ok(port) => {
47                tracing::debug!(key = "ATRG_APP__PORT", port, "applying env override");
48                config.app.port = port;
49            }
50            Err(e) => {
51                tracing::warn!(
52                    key = "ATRG_APP__PORT",
53                    value = %val,
54                    error = %e,
55                    "ignoring invalid ATRG_APP__PORT value"
56                );
57            }
58        }
59    }
60
61    if let Ok(val) = std::env::var("ATRG_APP__SECRET_KEY") {
62        tracing::debug!(key = "ATRG_APP__SECRET_KEY", "applying env override");
63        config.app.secret_key = val;
64    }
65
66    if let Ok(val) = std::env::var("ATRG_APP__ENVIRONMENT") {
67        tracing::debug!(key = "ATRG_APP__ENVIRONMENT", "applying env override");
68        config.app.environment = val;
69    }
70
71    if let Ok(val) = std::env::var("ATRG_APP__CORS_ORIGINS") {
72        tracing::debug!(key = "ATRG_APP__CORS_ORIGINS", "applying env override");
73        config.app.cors_origins = val
74            .split(',')
75            .map(|s| s.trim().to_string())
76            .filter(|s| !s.is_empty())
77            .collect();
78    }
79
80    if let Ok(val) = std::env::var("ATRG_APP__ADMIN_DIDS") {
81        tracing::debug!(key = "ATRG_APP__ADMIN_DIDS", "applying env override");
82        config.app.admin_dids = val
83            .split(',')
84            .map(|s| s.trim().to_string())
85            .filter(|s| !s.is_empty())
86            .collect();
87    }
88
89    // -- [auth] ---------------------------------------------------------
90    if let Ok(val) = std::env::var("ATRG_AUTH__CLIENT_ID") {
91        tracing::debug!(key = "ATRG_AUTH__CLIENT_ID", "applying env override");
92        config.auth.client_id = val;
93    }
94
95    if let Ok(val) = std::env::var("ATRG_AUTH__REDIRECT_URI") {
96        tracing::debug!(key = "ATRG_AUTH__REDIRECT_URI", "applying env override");
97        config.auth.redirect_uri = val;
98    }
99
100    if let Ok(val) = std::env::var("ATRG_AUTH__SCOPE") {
101        tracing::debug!(key = "ATRG_AUTH__SCOPE", "applying env override");
102        config.auth.scope = val;
103    }
104
105    if let Ok(val) = std::env::var("ATRG_AUTH__POST_LOGIN_REDIRECT") {
106        tracing::debug!(
107            key = "ATRG_AUTH__POST_LOGIN_REDIRECT",
108            "applying env override"
109        );
110        config.auth.post_login_redirect = val;
111    }
112
113    // -- [database] -----------------------------------------------------
114    if let Ok(val) = std::env::var("ATRG_DATABASE__URL") {
115        tracing::debug!(key = "ATRG_DATABASE__URL", "applying env override");
116        config.database.url = val;
117    }
118
119    // -- [firehose] -----------------------------------------------------
120    if let Ok(val) = std::env::var("ATRG_FIREHOSE__RELAY") {
121        if let Some(ref mut fh) = config.firehose {
122            tracing::debug!(key = "ATRG_FIREHOSE__RELAY", "applying env override");
123            fh.relay = val;
124        }
125    }
126
127    // -- [feed_generator] -----------------------------------------------
128    if let Ok(val) = std::env::var("ATRG_FEED_GENERATOR__DID") {
129        if let Some(ref mut fg) = config.feed_generator {
130            tracing::debug!(key = "ATRG_FEED_GENERATOR__DID", "applying env override");
131            fg.did = val;
132        }
133    }
134
135    // -- [labeler] ------------------------------------------------------
136    if let Ok(val) = std::env::var("ATRG_LABELER__DID") {
137        if let Some(ref mut lb) = config.labeler {
138            tracing::debug!(key = "ATRG_LABELER__DID", "applying env override");
139            lb.did = val;
140        }
141    }
142    if let Ok(val) = std::env::var("ATRG_LABELER__SIGNING_KEY_PATH") {
143        if let Some(ref mut lb) = config.labeler {
144            tracing::debug!(
145                key = "ATRG_LABELER__SIGNING_KEY_PATH",
146                "applying env override"
147            );
148            lb.signing_key_path = Some(val);
149        }
150    }
151    if let Ok(val) = std::env::var("ATRG_LABELER__SIGNING_KEY_BASE64") {
152        if let Some(ref mut lb) = config.labeler {
153            tracing::debug!(
154                key = "ATRG_LABELER__SIGNING_KEY_BASE64",
155                "applying env override"
156            );
157            lb.signing_key_base64 = Some(val);
158        }
159    }
160}
161
162// ---------------------------------------------------------------------------
163// Tests
164// ---------------------------------------------------------------------------
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::config::{AppConfig, AuthConfig, Config, DatabaseConfig};
170
171    /// Build a baseline config for testing. No file I/O needed.
172    fn base_config() -> Config {
173        Config {
174            app: AppConfig {
175                name: "test-app".into(),
176                host: "127.0.0.1".into(),
177                port: 3000,
178                secret_key: "abcdefghijklmnopqrstuvwxyz123456".into(),
179                cors_origins: vec![],
180                environment: "development".into(),
181                admin_dids: vec![],
182            },
183            auth: AuthConfig {
184                client_id: "http://localhost:3000/client-metadata.json".into(),
185                redirect_uri: "http://localhost:3000/auth/callback".into(),
186                scope: "atproto transition:generic".into(),
187                post_login_redirect: "/".into(),
188            },
189            database: DatabaseConfig {
190                url: "sqlite://atrg.db".into(),
191            },
192            jetstream: None,
193            firehose: None,
194            feed_generator: None,
195            labeler: None,
196            rate_limit: None,
197        }
198    }
199
200    /// Helper: set an env var, run a closure, then remove the var.
201    /// Ensures cleanup even on panic.
202    fn with_env_var<F: FnOnce()>(key: &str, value: &str, f: F) {
203        std::env::set_var(key, value);
204        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
205        std::env::remove_var(key);
206        if let Err(e) = result {
207            std::panic::resume_unwind(e);
208        }
209    }
210
211    #[test]
212    fn override_port() {
213        let mut cfg = base_config();
214        with_env_var("ATRG_APP__PORT", "9090", || {
215            apply_env_overrides(&mut cfg);
216        });
217        assert_eq!(cfg.app.port, 9090);
218    }
219
220    #[test]
221    fn override_secret_key() {
222        let mut cfg = base_config();
223        with_env_var(
224            "ATRG_APP__SECRET_KEY",
225            "new-super-secret-key-for-prod!!",
226            || {
227                apply_env_overrides(&mut cfg);
228            },
229        );
230        assert_eq!(cfg.app.secret_key, "new-super-secret-key-for-prod!!");
231    }
232
233    #[test]
234    fn override_database_url() {
235        let mut cfg = base_config();
236        with_env_var("ATRG_DATABASE__URL", "sqlite://prod.db", || {
237            apply_env_overrides(&mut cfg);
238        });
239        assert_eq!(cfg.database.url, "sqlite://prod.db");
240    }
241
242    #[test]
243    fn override_cors_origins_comma_separated() {
244        let mut cfg = base_config();
245        with_env_var(
246            "ATRG_APP__CORS_ORIGINS",
247            "http://localhost:5173, https://example.com , https://app.example.com",
248            || {
249                apply_env_overrides(&mut cfg);
250            },
251        );
252        assert_eq!(
253            cfg.app.cors_origins,
254            vec![
255                "http://localhost:5173",
256                "https://example.com",
257                "https://app.example.com",
258            ]
259        );
260    }
261
262    #[test]
263    fn invalid_port_is_ignored() {
264        let mut cfg = base_config();
265        let original_port = cfg.app.port;
266        with_env_var("ATRG_APP__PORT", "not_a_number", || {
267            apply_env_overrides(&mut cfg);
268        });
269        assert_eq!(cfg.app.port, original_port);
270    }
271
272    #[test]
273    fn override_app_name() {
274        let mut cfg = base_config();
275        with_env_var("ATRG_APP__NAME", "overridden-app", || {
276            apply_env_overrides(&mut cfg);
277        });
278        assert_eq!(cfg.app.name, "overridden-app");
279    }
280
281    #[test]
282    fn override_auth_client_id() {
283        let mut cfg = base_config();
284        with_env_var(
285            "ATRG_AUTH__CLIENT_ID",
286            "https://prod.example.com/client-metadata.json",
287            || {
288                apply_env_overrides(&mut cfg);
289            },
290        );
291        assert_eq!(
292            cfg.auth.client_id,
293            "https://prod.example.com/client-metadata.json"
294        );
295    }
296
297    #[test]
298    fn empty_cors_origins_value_produces_empty_vec() {
299        let mut cfg = base_config();
300        cfg.app.cors_origins = vec!["http://existing.example.com".into()];
301        with_env_var("ATRG_APP__CORS_ORIGINS", "", || {
302            apply_env_overrides(&mut cfg);
303        });
304        assert!(cfg.app.cors_origins.is_empty());
305    }
306
307    #[test]
308    fn absent_env_vars_leave_config_unchanged() {
309        // Make sure none of the ATRG_ vars are set.
310        for key in &[
311            "ATRG_APP__NAME",
312            "ATRG_APP__HOST",
313            "ATRG_APP__PORT",
314            "ATRG_APP__SECRET_KEY",
315            "ATRG_APP__ENVIRONMENT",
316            "ATRG_APP__CORS_ORIGINS",
317            "ATRG_AUTH__CLIENT_ID",
318            "ATRG_AUTH__REDIRECT_URI",
319            "ATRG_AUTH__SCOPE",
320            "ATRG_DATABASE__URL",
321        ] {
322            std::env::remove_var(key);
323        }
324
325        let mut cfg = base_config();
326        let before = format!("{:?}", cfg);
327        apply_env_overrides(&mut cfg);
328        let after = format!("{:?}", cfg);
329        assert_eq!(before, after);
330    }
331}