1use 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
35pub use attrs::AttrKeyInfo;
37pub use attrs::AttrValue;
38pub use attrs::Attrs;
39pub use attrs::Key;
40pub use attrs::SerializableValue;
41#[doc(hidden)]
43pub use bincode;
44pub use flattrs::Flattrs;
45pub use hyperactor_config_macros::AttrValue;
47pub use inventory::submit;
49pub use paste::paste;
50#[doc(hidden)]
52pub use typeuri;
53
54#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ConfigAttr {
73 pub env_name: Option<String>,
75
76 pub py_name: Option<String>,
79
80 pub propagate: bool,
83}
84
85impl ConfigAttr {
86 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct IntrospectAttr {
136 pub name: String,
138
139 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 pub attr CONFIG: ConfigAttr;
171
172 pub attr INTROSPECT: IntrospectAttr;
184}
185
186pub 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 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 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
247pub 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
255pub 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 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 #[cfg_attr(not(fbcode_build), ignore)]
415 fn test_from_env() {
416 unsafe { std::env::set_var("TEST_USIZE_KEY", "1024") };
419 unsafe { std::env::set_var("TEST_STRING_KEY", "world") };
421 unsafe { std::env::set_var("TEST_BOOL_KEY", "true") };
423 unsafe { std::env::set_var("TEST_I64_KEY", "-999") };
425 unsafe { std::env::set_var("TEST_F64_KEY", "2.718") };
427 unsafe { std::env::set_var("TEST_U32_KEY", "500") };
429 unsafe { std::env::set_var("TEST_DURATION_KEY", "5s") };
431 unsafe { std::env::set_var("TEST_MODE_KEY", "prod") };
433 unsafe { std::env::set_var("TEST_IP_KEY", "192.168.1.1") };
435 unsafe { std::env::set_var("TEST_SYSTEMTIME_KEY", "2024-01-15T10:30:00Z") };
437
438 let config = from_env();
439
440 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) );
454
455 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 logs_assert_unscoped(|logged_lines: &[&str]| {
486 let mut expected_lines = expected_lines.clone(); 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 unsafe { std::env::remove_var("TEST_USIZE_KEY") };
501 unsafe { std::env::remove_var("TEST_STRING_KEY") };
503 unsafe { std::env::remove_var("TEST_BOOL_KEY") };
505 unsafe { std::env::remove_var("TEST_I64_KEY") };
507 unsafe { std::env::remove_var("TEST_F64_KEY") };
509 unsafe { std::env::remove_var("TEST_U32_KEY") };
511 unsafe { std::env::remove_var("TEST_DURATION_KEY") };
513 unsafe { std::env::remove_var("TEST_MODE_KEY") };
515 unsafe { std::env::remove_var("TEST_IP_KEY") };
517 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 #[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}