hyperactor_config/
lib.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Core configuration and attribute infrastructure for Hyperactor.
10//!
11//! This crate provides the core infrastructure for type-safe configuration
12//! management including:
13//! - `ConfigAttr`: Metadata for configuration keys
14//! - Helper functions to load/save `Attrs` (from env via `from_env`,
15//!   from YAML via `from_yaml`, and `to_yaml`)
16//! - Global layered configuration store under [`crate::global`]
17//!
18//! Individual crates should declare their own config keys using `declare_attrs!`
19//! and import `ConfigAttr`, `CONFIG`, and other infrastructure from this crate.
20
21use std::env;
22use std::fs::File;
23use std::io::Read;
24use std::path::Path;
25
26use serde::Deserialize;
27use serde::Serialize;
28use shell_quote::QuoteRefExt;
29use typeuri::Named;
30
31pub mod attrs;
32pub mod flattrs;
33pub mod global;
34
35// Re-export commonly used items
36pub use attrs::AttrKeyInfo;
37pub use attrs::AttrValue;
38pub use attrs::Attrs;
39pub use attrs::Key;
40pub use attrs::SerializableValue;
41// Re-export bincode for macro usage (deserialize_bincode in AttrKeyInfo)
42#[doc(hidden)]
43pub use bincode;
44pub use flattrs::Flattrs;
45// Re-export AttrValue derive macro
46pub use hyperactor_config_macros::AttrValue;
47// Re-export macros needed by declare_attrs!
48pub use inventory::submit;
49pub use paste::paste;
50// Re-export typeuri for macro usage
51#[doc(hidden)]
52pub use typeuri;
53
54// declare_attrs is already exported via #[macro_export] in attrs.rs
55
56/// Metadata describing how a configuration key is exposed across
57/// environments.
58///
59/// Each `ConfigAttr` entry defines how a Rust configuration key maps
60/// to external representations:
61///  - `env_name`: the environment variable consulted by
62///    [`global::init_from_env()`] when loading configuration.
63///  - `py_name`: the Python keyword argument accepted by
64///    `monarch.configure(...)` and returned by `get_configuration()`.
65///  - `propagate`: whether this config should be inherited by child
66///    processes spawned via `BootstrapProcManager`. Set to `false`
67///    for process-local configs (e.g., TLS cert paths).
68///
69/// All configuration keys should carry this meta-attribute via
70/// `@meta(CONFIG = ConfigAttr { ... })`.
71#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ConfigAttr {
73    /// Environment variable consulted by `global::init_from_env()`.
74    pub env_name: Option<String>,
75
76    /// Python kwarg name used by `monarch.configure(...)` and
77    /// `get_configuration()`.
78    pub py_name: Option<String>,
79
80    /// Whether this config should be inherited by child processes.
81    /// Set to `false` for process-local configs like TLS cert paths.
82    pub propagate: bool,
83}
84
85impl ConfigAttr {
86    /// Create a new `ConfigAttr` with the given env/py names.
87    ///
88    /// Defaults to `propagate: true` (inherited by child processes).
89    /// Use [`process_local`](Self::process_local) for configs that
90    /// should not propagate.
91    pub fn new(env_name: Option<String>, py_name: Option<String>) -> Self {
92        Self {
93            env_name,
94            py_name,
95            propagate: true,
96        }
97    }
98
99    /// Mark this config as process-local (not propagated to children).
100    ///
101    /// Use for configs like TLS cert paths that are specific to the
102    /// current process.
103    pub fn process_local(mut self) -> Self {
104        self.propagate = false;
105        self
106    }
107}
108
109impl Named for ConfigAttr {
110    fn typename() -> &'static str {
111        "hyperactor_config::ConfigAttr"
112    }
113}
114
115impl AttrValue for ConfigAttr {
116    fn display(&self) -> String {
117        serde_json::to_string(self).unwrap_or_else(|_| "<invalid ConfigAttr>".into())
118    }
119    fn parse(s: &str) -> Result<Self, anyhow::Error> {
120        Ok(serde_json::from_str(s)?)
121    }
122}
123
124/// Metadata describing how an attribute key is exposed through the
125/// HTTP introspection schema.
126///
127/// Each `IntrospectAttr` value defines the public schema entry for a
128/// Rust attribute key as exposed by endpoints such as `GET
129/// /v1/_schema`:
130/// - `name`: short public key name used in HTTP JSON and schema
131///   output (for example, `"node_type"` instead of a fully qualified
132///   Rust path)
133/// - `desc`: human-readable description shown in the schema output
134#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct IntrospectAttr {
136    /// Short public name for the key in HTTP JSON and schema.
137    pub name: String,
138
139    /// Human-readable description for the schema endpoint.
140    pub desc: String,
141}
142
143impl Named for IntrospectAttr {
144    fn typename() -> &'static str {
145        "hyperactor_config::IntrospectAttr"
146    }
147}
148
149impl AttrValue for IntrospectAttr {
150    fn display(&self) -> String {
151        serde_json::to_string(self).unwrap_or_else(|_| "<invalid IntrospectAttr>".into())
152    }
153    fn parse(s: &str) -> Result<Self, anyhow::Error> {
154        Ok(serde_json::from_str(s)?)
155    }
156}
157
158declare_attrs! {
159    /// This is a meta-attribute marking a configuration key.
160    ///
161    /// It carries metadata used to bridge Rust, environment
162    /// variables, and Python:
163    ///  - `env_name`: environment variable name consulted by
164    ///    `global::init_from_env()`.
165    ///  - `py_name`: keyword argument name recognized by
166    ///    `monarch.configure(...)`.
167    ///
168    /// All configuration keys should be annotated with this
169    /// attribute.
170    pub attr CONFIG: ConfigAttr;
171
172    /// This is a meta-attribute marking a key as part of the HTTP
173    /// introspection schema.
174    ///
175    /// It carries the public schema metadata used to expose actor and
176    /// mesh topology attributes through the introspection API.
177    ///
178    /// Keys that should be visible through the introspection HTTP
179    /// surface should be annotated with this attribute, for example:
180    ///
181    /// `@meta(INTROSPECT = IntrospectAttr { name: "...".into(), desc:
182    /// "...".into() })`
183    pub attr INTROSPECT: IntrospectAttr;
184}
185
186/// Load configuration from environment variables
187pub fn from_env() -> Attrs {
188    let mut config = Attrs::new();
189    let mut output = String::new();
190
191    fn export(env_var: &str, value: Option<&dyn SerializableValue>) -> String {
192        let env_var: String = env_var.quoted(shell_quote::Bash);
193        let value: String = value
194            .map_or("".to_string(), SerializableValue::display)
195            .quoted(shell_quote::Bash);
196        format!("export {}={}\n", env_var, value)
197    }
198
199    for key in inventory::iter::<AttrKeyInfo>() {
200        // Skip keys that are not marked as CONFIG or that do not
201        // declare an environment variable mapping. Only CONFIG-marked
202        // keys with an `env_name` participate in environment
203        // initialization.
204        let Some(cfg_meta) = key.meta.get(CONFIG) else {
205            continue;
206        };
207        let Some(env_var) = cfg_meta.env_name.as_deref() else {
208            continue;
209        };
210
211        let Ok(val) = env::var(env_var) else {
212            // Default value
213            output.push_str("# ");
214            output.push_str(&export(env_var, key.default));
215            continue;
216        };
217
218        match (key.parse)(&val) {
219            Err(e) => {
220                tracing::error!(
221                    "failed to override config key {} from value \"{}\" in ${}: {})",
222                    key.name,
223                    val,
224                    env_var,
225                    e
226                );
227                output.push_str("# ");
228                output.push_str(&export(env_var, key.default));
229            }
230            Ok(parsed) => {
231                output.push_str("# ");
232                output.push_str(&export(env_var, key.default));
233                output.push_str(&export(env_var, Some(parsed.as_ref())));
234                config.insert_value_by_name_unchecked(key.name, parsed);
235            }
236        }
237    }
238
239    tracing::info!(
240        "loaded configuration from environment:\n{}",
241        output.trim_end()
242    );
243
244    config
245}
246
247/// Load configuration from a YAML file
248pub fn from_yaml<P: AsRef<Path>>(path: P) -> Result<Attrs, anyhow::Error> {
249    let mut file = File::open(path)?;
250    let mut contents = String::new();
251    file.read_to_string(&mut contents)?;
252    Ok(serde_yaml::from_str(&contents)?)
253}
254
255/// Save configuration to a YAML file
256pub fn to_yaml<P: AsRef<Path>>(attrs: &Attrs, path: P) -> Result<(), anyhow::Error> {
257    let yaml = serde_yaml::to_string(attrs)?;
258    std::fs::write(path, yaml)?;
259    Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264    use std::collections::HashSet;
265    use std::net::Ipv4Addr;
266
267    use indoc::indoc;
268
269    use crate::CONFIG;
270    use crate::ConfigAttr;
271    use crate::attrs::declare_attrs;
272    use crate::from_env;
273    use crate::from_yaml;
274    use crate::to_yaml;
275
276    /// Like the `logs_assert` injected by `#[traced_test]`, but without scope
277    /// filtering. Use when asserting on events emitted outside the test's span
278    /// (e.g. from spawned tasks or panic hooks).
279    fn logs_assert_unscoped(f: impl Fn(&[&str]) -> Result<(), String>) {
280        let buf = tracing_test::internal::global_buf().lock().unwrap();
281        let logs_str = std::str::from_utf8(&buf).expect("Logs contain invalid UTF8");
282        let lines: Vec<&str> = logs_str.lines().collect();
283        match f(&lines) {
284            Ok(()) => {}
285            Err(msg) => panic!("{}", msg),
286        }
287    }
288
289    #[derive(
290        Debug,
291        Clone,
292        Copy,
293        PartialEq,
294        Eq,
295        serde::Serialize,
296        serde::Deserialize
297    )]
298    pub(crate) enum TestMode {
299        Development,
300        Staging,
301        Production,
302    }
303
304    impl std::fmt::Display for TestMode {
305        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306            match self {
307                TestMode::Development => write!(f, "dev"),
308                TestMode::Staging => write!(f, "staging"),
309                TestMode::Production => write!(f, "prod"),
310            }
311        }
312    }
313
314    impl std::str::FromStr for TestMode {
315        type Err = anyhow::Error;
316
317        fn from_str(s: &str) -> Result<Self, Self::Err> {
318            match s {
319                "dev" => Ok(TestMode::Development),
320                "staging" => Ok(TestMode::Staging),
321                "prod" => Ok(TestMode::Production),
322                _ => Err(anyhow::anyhow!("unknown mode: {}", s)),
323            }
324        }
325    }
326
327    impl typeuri::Named for TestMode {
328        fn typename() -> &'static str {
329            "hyperactor_config::tests::TestMode"
330        }
331    }
332
333    impl crate::attrs::AttrValue for TestMode {
334        fn display(&self) -> String {
335            self.to_string()
336        }
337
338        fn parse(s: &str) -> Result<Self, anyhow::Error> {
339            s.parse()
340        }
341    }
342
343    declare_attrs! {
344        @meta(CONFIG = ConfigAttr::new(
345            Some("TEST_USIZE_KEY".to_string()),
346            None,
347        ))
348        pub attr USIZE_KEY: usize = 10;
349
350        @meta(CONFIG = ConfigAttr::new(
351            Some("TEST_STRING_KEY".to_string()),
352            None,
353        ))
354        pub attr STRING_KEY: String = String::new();
355
356        @meta(CONFIG = ConfigAttr::new(
357            Some("TEST_BOOL_KEY".to_string()),
358            None,
359        ))
360        pub attr BOOL_KEY: bool = false;
361
362        @meta(CONFIG = ConfigAttr::new(
363            Some("TEST_I64_KEY".to_string()),
364            None,
365        ))
366        pub attr I64_KEY: i64 = -42;
367
368        @meta(CONFIG = ConfigAttr::new(
369            Some("TEST_F64_KEY".to_string()),
370            None,
371        ))
372        pub attr F64_KEY: f64 = 3.14;
373
374        @meta(CONFIG = ConfigAttr::new(
375            Some("TEST_U32_KEY".to_string()),
376            Some("test_u32_key".to_string()),
377        ))
378        pub attr U32_KEY: u32 = 100;
379
380        @meta(CONFIG = ConfigAttr::new(
381            Some("TEST_DURATION_KEY".to_string()),
382            None,
383        ))
384        pub attr DURATION_KEY: std::time::Duration = std::time::Duration::from_mins(1);
385
386        @meta(CONFIG = ConfigAttr::new(
387            Some("TEST_MODE_KEY".to_string()),
388            None,
389        ))
390        pub attr MODE_KEY: TestMode = TestMode::Development;
391
392        @meta(CONFIG = ConfigAttr::new(
393            Some("TEST_IP_KEY".to_string()),
394            None,
395        ))
396        pub attr IP_KEY: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
397
398        @meta(CONFIG = ConfigAttr::new(
399            Some("TEST_SYSTEMTIME_KEY".to_string()),
400            None,
401        ))
402        pub attr SYSTEMTIME_KEY: std::time::SystemTime = std::time::UNIX_EPOCH;
403
404        @meta(CONFIG = ConfigAttr::new(
405            None,
406            Some("test_no_env_key".to_string()),
407        ))
408        pub attr NO_ENV_KEY: usize = 999;
409    }
410
411    #[tracing_test::traced_test]
412    #[test]
413    // TODO: OSS: The logs_assert function returned an error: missing log lines: {"# export HYPERACTOR_DEFAULT_ENCODING=serde_multipart", ...}
414    #[cfg_attr(not(fbcode_build), ignore)]
415    fn test_from_env() {
416        // Set environment variables
417        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
418        unsafe { std::env::set_var("TEST_USIZE_KEY", "1024") };
419        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
420        unsafe { std::env::set_var("TEST_STRING_KEY", "world") };
421        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
422        unsafe { std::env::set_var("TEST_BOOL_KEY", "true") };
423        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
424        unsafe { std::env::set_var("TEST_I64_KEY", "-999") };
425        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
426        unsafe { std::env::set_var("TEST_F64_KEY", "2.718") };
427        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
428        unsafe { std::env::set_var("TEST_U32_KEY", "500") };
429        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
430        unsafe { std::env::set_var("TEST_DURATION_KEY", "5s") };
431        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
432        unsafe { std::env::set_var("TEST_MODE_KEY", "prod") };
433        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
434        unsafe { std::env::set_var("TEST_IP_KEY", "192.168.1.1") };
435        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
436        unsafe { std::env::set_var("TEST_SYSTEMTIME_KEY", "2024-01-15T10:30:00Z") };
437
438        let config = from_env();
439
440        // Verify values loaded from environment
441        assert_eq!(config[USIZE_KEY], 1024);
442        assert_eq!(config[STRING_KEY], "world");
443        assert!(config[BOOL_KEY]);
444        assert_eq!(config[I64_KEY], -999);
445        assert_eq!(config[F64_KEY], 2.718);
446        assert_eq!(config[U32_KEY], 500);
447        assert_eq!(config[DURATION_KEY], std::time::Duration::from_secs(5));
448        assert_eq!(config[MODE_KEY], TestMode::Production);
449        assert_eq!(config[IP_KEY], Ipv4Addr::new(192, 168, 1, 1));
450        assert_eq!(
451            config[SYSTEMTIME_KEY],
452            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1705314600) // 2024-01-15T10:30:00Z
453        );
454
455        // Verify key without env_name uses default
456        assert_eq!(config[NO_ENV_KEY], 999);
457
458        let expected_lines: HashSet<&str> = indoc! {"
459            # export TEST_USIZE_KEY=10
460            export TEST_USIZE_KEY=1024
461            # export TEST_STRING_KEY=''
462            export TEST_STRING_KEY=world
463            # export TEST_BOOL_KEY=0
464            export TEST_BOOL_KEY=1
465            # export TEST_I64_KEY=-42
466            export TEST_I64_KEY=-999
467            # export TEST_F64_KEY=3.14
468            export TEST_F64_KEY=2.718
469            # export TEST_U32_KEY=100
470            export TEST_U32_KEY=500
471            # export TEST_DURATION_KEY=1m
472            export TEST_DURATION_KEY=5s
473            # export TEST_MODE_KEY=dev
474            export TEST_MODE_KEY=prod
475            # export TEST_IP_KEY=127.0.0.1
476            export TEST_IP_KEY=192.168.1.1
477        "}
478        .trim_end()
479        .lines()
480        .collect();
481
482        // For some reason, logs_contain fails to find these lines individually
483        // (possibly to do with the fact that we have newlines in our log entries);
484        // instead, we test it manually.
485        logs_assert_unscoped(|logged_lines: &[&str]| {
486            let mut expected_lines = expected_lines.clone(); // this is an `Fn` closure
487            for logged in logged_lines {
488                expected_lines.remove(logged);
489            }
490
491            if expected_lines.is_empty() {
492                Ok(())
493            } else {
494                Err(format!("missing log lines: {:?}", expected_lines))
495            }
496        });
497
498        // Clean up
499        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
500        unsafe { std::env::remove_var("TEST_USIZE_KEY") };
501        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
502        unsafe { std::env::remove_var("TEST_STRING_KEY") };
503        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
504        unsafe { std::env::remove_var("TEST_BOOL_KEY") };
505        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
506        unsafe { std::env::remove_var("TEST_I64_KEY") };
507        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
508        unsafe { std::env::remove_var("TEST_F64_KEY") };
509        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
510        unsafe { std::env::remove_var("TEST_U32_KEY") };
511        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
512        unsafe { std::env::remove_var("TEST_DURATION_KEY") };
513        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
514        unsafe { std::env::remove_var("TEST_MODE_KEY") };
515        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
516        unsafe { std::env::remove_var("TEST_IP_KEY") };
517        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
518        unsafe { std::env::remove_var("TEST_SYSTEMTIME_KEY") };
519    }
520
521    #[test]
522    fn test_yaml_round_trip() {
523        let temp_path = std::env::temp_dir().join("test_config.yaml");
524
525        let mut config = crate::Attrs::new();
526        config.set(USIZE_KEY, 2048);
527        config.set(STRING_KEY, "hello_yaml".to_string());
528        config.set(BOOL_KEY, true);
529        config.set(I64_KEY, -123);
530        config.set(F64_KEY, 1.414);
531        config.set(U32_KEY, 777);
532        config.set(DURATION_KEY, std::time::Duration::from_mins(2));
533        config.set(MODE_KEY, TestMode::Staging);
534        config.set(IP_KEY, Ipv4Addr::new(10, 0, 0, 1));
535        config.set(
536            SYSTEMTIME_KEY,
537            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1609459200),
538        );
539
540        to_yaml(&config, &temp_path).unwrap();
541
542        let yaml_content = std::fs::read_to_string(&temp_path).unwrap();
543
544        eprintln!("YAML content:\n{}", yaml_content);
545
546        assert!(yaml_content.contains("2048"));
547        assert!(yaml_content.contains("hello_yaml"));
548        assert!(yaml_content.contains("Staging"));
549
550        let loaded_config = from_yaml(&temp_path).unwrap();
551
552        assert_eq!(loaded_config[USIZE_KEY], 2048);
553        assert_eq!(loaded_config[STRING_KEY], "hello_yaml");
554        assert!(loaded_config[BOOL_KEY]);
555        assert_eq!(loaded_config[I64_KEY], -123);
556        assert_eq!(loaded_config[F64_KEY], 1.414);
557        assert_eq!(loaded_config[U32_KEY], 777);
558        assert_eq!(
559            loaded_config[DURATION_KEY],
560            std::time::Duration::from_mins(2)
561        );
562        assert_eq!(loaded_config[MODE_KEY], TestMode::Staging);
563        assert_eq!(loaded_config[IP_KEY], Ipv4Addr::new(10, 0, 0, 1));
564        assert_eq!(
565            loaded_config[SYSTEMTIME_KEY],
566            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1609459200)
567        );
568
569        let _ = std::fs::remove_file(&temp_path);
570    }
571
572    // Verify that the INTROSPECT meta-attribute attaches structured
573    // introspection metadata to an attribute key and that the
574    // metadata can be retrieved through the attrs API.
575    //
576    // The declare_attrs! block defines a key annotated with
577    // `@meta(INTROSPECT = IntrospectAttr { ... })`. Calling
578    // `.attrs()` on the key should expose that metadata, and
579    // `meta.get(INTROSPECT)` should return the stored
580    // `IntrospectAttr` with the declared `name` and `desc` values.
581    #[test]
582    fn test_introspect_meta_key() {
583        use crate::INTROSPECT;
584        use crate::IntrospectAttr;
585
586        declare_attrs! {
587            @meta(INTROSPECT = IntrospectAttr {
588                name: "test_key".into(),
589                desc: "A test introspection key".into(),
590            })
591            attr TEST_INTROSPECT_KEY: String;
592        }
593        let meta = TEST_INTROSPECT_KEY.attrs();
594        let introspect = meta
595            .get(INTROSPECT)
596            .expect("INTROSPECT meta-attr should be set");
597        assert_eq!(introspect.name, "test_key");
598        assert_eq!(introspect.desc, "A test introspection key");
599    }
600}