config.rs (21744B)
1 use anyhow::{Context, Result, bail}; 2 use radroots_nostr::prelude::RadrootsNostrMetadata; 3 use radroots_runtime::{BackoffConfig, RadrootsNostrServiceConfig}; 4 use serde::{Deserialize, Serialize}; 5 use std::path::{Path, PathBuf}; 6 7 use crate::features::trade_validation_receipt::TradeValidationReceiptProverPolicy; 8 use crate::paths::{ 9 RhiRuntimePaths, default_subscriber_state_path_for_process, resolve_runtime_paths_with_resolver, 10 }; 11 12 fn default_replay_window_secs() -> u64 { 13 24 * 60 * 60 14 } 15 16 fn default_replay_overlap_secs() -> u64 { 17 5 * 60 18 } 19 20 fn default_logging_filter() -> String { 21 "info".to_owned() 22 } 23 24 fn default_logging_stdout() -> bool { 25 true 26 } 27 28 #[derive(Debug, Clone, Serialize, Deserialize)] 29 pub struct LoggingConfig { 30 pub output_dir: PathBuf, 31 pub filter: String, 32 pub stdout: bool, 33 } 34 35 #[derive(Debug, Deserialize, Clone, Default)] 36 #[serde(default, deny_unknown_fields)] 37 struct RawLoggingConfig { 38 pub output_dir: Option<PathBuf>, 39 pub filter: Option<String>, 40 pub stdout: Option<bool>, 41 } 42 43 impl RawLoggingConfig { 44 fn into_logging_config(self, paths: &RhiRuntimePaths) -> Result<LoggingConfig> { 45 let filter = self.filter.unwrap_or_else(default_logging_filter); 46 let filter = filter.trim(); 47 if filter.is_empty() { 48 bail!("logging.filter must not be empty"); 49 } 50 51 Ok(LoggingConfig { 52 output_dir: self.output_dir.unwrap_or_else(|| paths.logs_dir.clone()), 53 filter: filter.to_owned(), 54 stdout: self.stdout.unwrap_or_else(default_logging_stdout), 55 }) 56 } 57 } 58 59 #[derive(Debug, Deserialize, Clone, Default)] 60 #[serde(default, deny_unknown_fields)] 61 struct RawRelaysConfig { 62 pub urls: Vec<String>, 63 } 64 65 #[derive(Debug, Deserialize, Clone, Default)] 66 #[serde(default, deny_unknown_fields)] 67 struct RawNostrConfig { 68 pub nip89: RawNip89Config, 69 } 70 71 #[derive(Debug, Deserialize, Clone, Default)] 72 #[serde(default, deny_unknown_fields)] 73 struct RawNip89Config { 74 pub identifier: Option<String>, 75 pub extra_tags: Vec<Vec<String>>, 76 } 77 78 #[derive(Debug, Clone)] 79 struct RawServiceConfig { 80 pub logging: LoggingConfig, 81 pub relays: RawRelaysConfig, 82 pub nostr: RawNostrConfig, 83 } 84 85 impl RawServiceConfig { 86 fn into_service_config(self) -> RadrootsNostrServiceConfig { 87 RadrootsNostrServiceConfig { 88 logs_dir: self.logging.output_dir.display().to_string(), 89 relays: self.relays.urls, 90 nip89_identifier: self.nostr.nip89.identifier, 91 nip89_extra_tags: self.nostr.nip89.extra_tags, 92 } 93 } 94 } 95 96 #[derive(Debug, Clone, Serialize, Deserialize)] 97 pub struct Configuration { 98 #[serde(flatten)] 99 pub service: RadrootsNostrServiceConfig, 100 pub logging: LoggingConfig, 101 #[serde(default)] 102 pub subscriber: SubscriberConfig, 103 #[serde(default)] 104 pub trade_validation_receipt: TradeValidationReceiptProverPolicy, 105 } 106 107 #[derive(Debug, Clone, Serialize, Deserialize, Default)] 108 pub struct SubscriberConfig { 109 #[serde(default)] 110 pub backoff: BackoffConfig, 111 #[serde(default)] 112 pub state: SubscriberStateConfig, 113 } 114 115 #[derive(Debug, Deserialize, Clone, Default)] 116 #[serde(default, deny_unknown_fields)] 117 struct RawSubscriberConfig { 118 #[serde(default)] 119 pub backoff: BackoffConfig, 120 #[serde(default)] 121 pub state: RawSubscriberStateConfig, 122 } 123 124 impl RawSubscriberConfig { 125 fn into_subscriber_config(self, paths: &RhiRuntimePaths) -> SubscriberConfig { 126 SubscriberConfig { 127 backoff: self.backoff, 128 state: self.state.into_subscriber_state_config(paths), 129 } 130 } 131 } 132 133 #[derive(Debug, Clone, Serialize, Deserialize)] 134 pub struct SubscriberStateConfig { 135 pub path: PathBuf, 136 pub replay_window_secs: u64, 137 pub replay_overlap_secs: u64, 138 } 139 140 #[derive(Debug, Deserialize, Clone)] 141 #[serde(deny_unknown_fields)] 142 struct RawSubscriberStateConfig { 143 #[serde(default)] 144 pub path: Option<PathBuf>, 145 #[serde(default = "default_replay_window_secs")] 146 pub replay_window_secs: u64, 147 #[serde(default = "default_replay_overlap_secs")] 148 pub replay_overlap_secs: u64, 149 } 150 151 impl Default for RawSubscriberStateConfig { 152 fn default() -> Self { 153 Self { 154 path: None, 155 replay_window_secs: default_replay_window_secs(), 156 replay_overlap_secs: default_replay_overlap_secs(), 157 } 158 } 159 } 160 161 impl RawSubscriberStateConfig { 162 fn into_subscriber_state_config(self, paths: &RhiRuntimePaths) -> SubscriberStateConfig { 163 SubscriberStateConfig { 164 path: self 165 .path 166 .unwrap_or_else(|| paths.subscriber_state_path.clone()), 167 replay_window_secs: self.replay_window_secs, 168 replay_overlap_secs: self.replay_overlap_secs, 169 } 170 } 171 } 172 173 impl Default for SubscriberStateConfig { 174 fn default() -> Self { 175 Self { 176 path: default_subscriber_state_path_for_process() 177 .expect("resolve canonical rhi subscriber state path"), 178 replay_window_secs: default_replay_window_secs(), 179 replay_overlap_secs: default_replay_overlap_secs(), 180 } 181 } 182 } 183 184 #[derive(Debug, Deserialize, Clone)] 185 #[serde(deny_unknown_fields)] 186 struct RawSettings { 187 pub metadata: RadrootsNostrMetadata, 188 #[serde(default)] 189 pub logging: RawLoggingConfig, 190 #[serde(default)] 191 pub relays: RawRelaysConfig, 192 #[serde(default)] 193 pub nostr: RawNostrConfig, 194 #[serde(default)] 195 pub subscriber: RawSubscriberConfig, 196 #[serde(default)] 197 pub trade_validation_receipt: TradeValidationReceiptProverPolicy, 198 } 199 200 impl RawSettings { 201 fn into_settings(self, paths: &RhiRuntimePaths) -> Result<Settings> { 202 let logging = self.logging.into_logging_config(paths)?; 203 let service = RawServiceConfig { 204 logging: logging.clone(), 205 relays: self.relays, 206 nostr: self.nostr, 207 } 208 .into_service_config(); 209 210 Ok(Settings { 211 metadata: self.metadata, 212 config: Configuration { 213 service, 214 logging, 215 subscriber: self.subscriber.into_subscriber_config(paths), 216 trade_validation_receipt: self.trade_validation_receipt, 217 }, 218 }) 219 } 220 } 221 222 #[derive(Debug, Clone, Serialize, Deserialize)] 223 pub struct Settings { 224 pub metadata: RadrootsNostrMetadata, 225 pub config: Configuration, 226 } 227 228 fn load_settings_from_path_with_resolver( 229 path: &Path, 230 resolver: &radroots_runtime_paths::RadrootsPathResolver, 231 profile: radroots_runtime_paths::RadrootsPathProfile, 232 repo_local_root: Option<&Path>, 233 ) -> Result<Settings> { 234 let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?; 235 let raw = std::fs::read_to_string(path) 236 .with_context(|| format!("read configuration from {}", path.display()))?; 237 let settings: RawSettings = 238 toml::from_str(&raw).with_context(|| format!("parse configuration {}", path.display()))?; 239 settings.into_settings(&paths) 240 } 241 242 pub fn load_settings_from_path(path: &Path) -> Result<Settings> { 243 let (profile, repo_local_root) = crate::paths::process_path_selection()?; 244 load_settings_from_path_with_resolver( 245 path, 246 &radroots_runtime_paths::RadrootsPathResolver::current(), 247 profile, 248 repo_local_root.as_deref(), 249 ) 250 } 251 252 #[cfg(test)] 253 mod tests { 254 use super::load_settings_from_path_with_resolver; 255 use crate::features::trade_validation_receipt::TradeValidationReceiptProverBackend; 256 use crate::paths::{ 257 default_subscriber_state_path_for_process, resolve_runtime_paths_with_resolver, 258 runtime_contract_with_resolver, 259 }; 260 use radroots_runtime_paths::{ 261 RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, 262 RadrootsPlatform, RadrootsRuntimeNamespace, 263 }; 264 use radroots_sp1_host_trade::RadrootsSp1TradeProofMode; 265 use std::path::PathBuf; 266 267 fn linux_resolver() -> RadrootsPathResolver { 268 RadrootsPathResolver::new( 269 RadrootsPlatform::Linux, 270 RadrootsHostEnvironment { 271 home_dir: Some(PathBuf::from("/home/treesap")), 272 ..RadrootsHostEnvironment::default() 273 }, 274 ) 275 } 276 277 #[test] 278 fn worker_namespace_uses_canonical_interactive_roots() { 279 let namespace = RadrootsRuntimeNamespace::worker("rhi").expect("worker namespace"); 280 let namespaced = linux_resolver() 281 .resolve( 282 RadrootsPathProfile::InteractiveUser, 283 &RadrootsPathOverrides::default(), 284 ) 285 .expect("interactive_user roots") 286 .namespaced(&namespace); 287 288 assert_eq!( 289 namespaced.config, 290 PathBuf::from("/home/treesap/.radroots/config/workers/rhi") 291 ); 292 assert_eq!( 293 namespaced.data, 294 PathBuf::from("/home/treesap/.radroots/data/workers/rhi") 295 ); 296 assert_eq!( 297 namespaced.logs, 298 PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") 299 ); 300 assert_eq!( 301 namespaced.secrets, 302 PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi") 303 ); 304 } 305 306 #[test] 307 fn runtime_paths_follow_interactive_user_contract() { 308 let paths = resolve_runtime_paths_with_resolver( 309 &linux_resolver(), 310 RadrootsPathProfile::InteractiveUser, 311 None, 312 ) 313 .expect("interactive_user paths should resolve"); 314 315 assert_eq!( 316 paths.config_path, 317 PathBuf::from("/home/treesap/.radroots/config/workers/rhi/config.toml") 318 ); 319 assert_eq!( 320 paths.logs_dir, 321 PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") 322 ); 323 assert_eq!( 324 paths.identity_path, 325 PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json") 326 ); 327 assert_eq!( 328 paths.subscriber_state_path, 329 PathBuf::from("/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json") 330 ); 331 } 332 333 #[test] 334 fn runtime_paths_follow_service_host_contract() { 335 let resolver = 336 RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); 337 let paths = 338 resolve_runtime_paths_with_resolver(&resolver, RadrootsPathProfile::ServiceHost, None) 339 .expect("service_host paths should resolve"); 340 341 assert_eq!( 342 paths.config_path, 343 PathBuf::from("/etc/radroots/workers/rhi/config.toml") 344 ); 345 assert_eq!( 346 paths.logs_dir, 347 PathBuf::from("/var/log/radroots/workers/rhi") 348 ); 349 assert_eq!( 350 paths.identity_path, 351 PathBuf::from("/etc/radroots/secrets/workers/rhi/identity.secret.json") 352 ); 353 assert_eq!( 354 paths.subscriber_state_path, 355 PathBuf::from("/var/lib/radroots/workers/rhi/trade-listing/state.json") 356 ); 357 } 358 359 #[test] 360 fn runtime_paths_follow_repo_local_contract() { 361 let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/rhi"); 362 let paths = resolve_runtime_paths_with_resolver( 363 &linux_resolver(), 364 RadrootsPathProfile::RepoLocal, 365 Some(repo_local_root.as_path()), 366 ) 367 .expect("repo_local paths should resolve"); 368 369 assert_eq!( 370 paths.config_path, 371 repo_local_root.join("config/workers/rhi/config.toml") 372 ); 373 assert_eq!(paths.logs_dir, repo_local_root.join("logs/workers/rhi")); 374 assert_eq!( 375 paths.identity_path, 376 repo_local_root.join("secrets/workers/rhi/identity.secret.json") 377 ); 378 assert_eq!( 379 paths.subscriber_state_path, 380 repo_local_root.join("data/workers/rhi/trade-listing/state.json") 381 ); 382 } 383 384 #[test] 385 fn load_settings_materializes_profile_defaults_when_paths_are_omitted() { 386 let temp = tempfile::tempdir().expect("tempdir"); 387 let config_path = temp.path().join("config.toml"); 388 std::fs::write( 389 &config_path, 390 r#" 391 [metadata] 392 name = "rhi-test" 393 394 [relays] 395 urls = ["wss://relay.example.com"] 396 397 [nostr.nip89] 398 identifier = "rhi" 399 400 [subscriber.state] 401 replay_window_secs = 123 402 replay_overlap_secs = 45 403 "#, 404 ) 405 .expect("write config"); 406 407 let settings = load_settings_from_path_with_resolver( 408 &config_path, 409 &linux_resolver(), 410 RadrootsPathProfile::InteractiveUser, 411 None, 412 ) 413 .expect("load settings"); 414 415 assert_eq!( 416 settings.config.service.logs_dir, 417 "/home/treesap/.radroots/logs/workers/rhi" 418 ); 419 assert_eq!( 420 settings.config.logging.output_dir, 421 PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") 422 ); 423 assert_eq!(settings.config.logging.filter, "info"); 424 assert!(settings.config.logging.stdout); 425 assert_eq!( 426 settings.config.service.relays, 427 vec!["wss://relay.example.com"] 428 ); 429 assert_eq!( 430 settings.config.service.nip89_identifier.as_deref(), 431 Some("rhi") 432 ); 433 assert_eq!( 434 settings.config.subscriber.state.path, 435 PathBuf::from("/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json") 436 ); 437 assert_eq!(settings.config.subscriber.state.replay_window_secs, 123); 438 assert_eq!(settings.config.subscriber.state.replay_overlap_secs, 45); 439 assert_eq!( 440 settings.config.trade_validation_receipt.backend, 441 TradeValidationReceiptProverBackend::Disabled 442 ); 443 assert_eq!( 444 settings.config.trade_validation_receipt.proof_mode, 445 RadrootsSp1TradeProofMode::None 446 ); 447 } 448 449 #[test] 450 fn load_settings_parses_trade_validation_receipt_policy() { 451 let temp = tempfile::tempdir().expect("tempdir"); 452 let config_path = temp.path().join("config.toml"); 453 std::fs::write( 454 &config_path, 455 r#" 456 [metadata] 457 name = "rhi-test" 458 459 [logging] 460 output_dir = "logs/rhi" 461 filter = "warn" 462 stdout = false 463 464 [relays] 465 urls = ["wss://relay.example.com"] 466 467 [nostr.nip89] 468 identifier = "rhi" 469 extra_tags = [["t", "radroots"]] 470 471 [subscriber.backoff] 472 base_ms = 10 473 max_ms = 100 474 factor = 3 475 jitter_ms = 5 476 477 [subscriber.state] 478 path = "state/trade-listing.json" 479 480 [trade_validation_receipt] 481 backend = "deterministic_none" 482 proof_mode = "none" 483 "#, 484 ) 485 .expect("write config"); 486 487 let settings = load_settings_from_path_with_resolver( 488 &config_path, 489 &linux_resolver(), 490 RadrootsPathProfile::InteractiveUser, 491 None, 492 ) 493 .expect("load settings"); 494 495 assert_eq!(settings.config.service.logs_dir, "logs/rhi"); 496 assert_eq!( 497 settings.config.logging.output_dir, 498 PathBuf::from("logs/rhi") 499 ); 500 assert_eq!(settings.config.logging.filter, "warn"); 501 assert!(!settings.config.logging.stdout); 502 assert_eq!( 503 settings.config.service.relays, 504 vec!["wss://relay.example.com"] 505 ); 506 assert_eq!( 507 settings.config.service.nip89_identifier.as_deref(), 508 Some("rhi") 509 ); 510 assert_eq!( 511 settings.config.service.nip89_extra_tags, 512 vec![vec!["t".to_owned(), "radroots".to_owned()]] 513 ); 514 assert_eq!(settings.config.subscriber.backoff.base_ms, 10); 515 assert_eq!(settings.config.subscriber.backoff.max_ms, 100); 516 assert_eq!(settings.config.subscriber.backoff.factor, 3); 517 assert_eq!(settings.config.subscriber.backoff.jitter_ms, 5); 518 assert_eq!( 519 settings.config.subscriber.state.path, 520 PathBuf::from("state/trade-listing.json") 521 ); 522 assert_eq!( 523 settings.config.trade_validation_receipt.backend, 524 TradeValidationReceiptProverBackend::DeterministicNone 525 ); 526 assert_eq!( 527 settings.config.trade_validation_receipt.proof_mode, 528 RadrootsSp1TradeProofMode::None 529 ); 530 } 531 532 #[test] 533 fn old_config_roots_are_rejected() { 534 let temp = tempfile::tempdir().expect("tempdir"); 535 for (name, body, needle) in [ 536 ( 537 "config-root", 538 r#" 539 [metadata] 540 name = "rhi-test" 541 542 [config] 543 relays = ["wss://relay.example.com"] 544 "#, 545 "unknown field `config`", 546 ), 547 ( 548 "config-subscriber-backoff", 549 r#" 550 [metadata] 551 name = "rhi-test" 552 553 [config.subscriber.backoff] 554 base_ms = 10 555 "#, 556 "unknown field `config`", 557 ), 558 ( 559 "config-subscriber-state", 560 r#" 561 [metadata] 562 name = "rhi-test" 563 564 [config.subscriber.state] 565 replay_window_secs = 10 566 "#, 567 "unknown field `config`", 568 ), 569 ( 570 "config-trade-validation-receipt", 571 r#" 572 [metadata] 573 name = "rhi-test" 574 575 [config.trade_validation_receipt] 576 backend = "deterministic_none" 577 proof_mode = "none" 578 "#, 579 "unknown field `config`", 580 ), 581 ] { 582 let config_path = temp.path().join(format!("{name}.toml")); 583 std::fs::write(&config_path, body).expect("write config"); 584 585 let error = load_settings_from_path_with_resolver( 586 &config_path, 587 &linux_resolver(), 588 RadrootsPathProfile::InteractiveUser, 589 None, 590 ) 591 .expect_err("old config root must fail"); 592 let message = format!("{error:#}"); 593 assert!(message.contains(needle), "{message}"); 594 } 595 } 596 597 #[test] 598 fn default_subscriber_state_path_is_canonical_for_current_process() { 599 let path = 600 default_subscriber_state_path_for_process().expect("resolve current process defaults"); 601 assert!(path.ends_with("trade-listing/state.json")); 602 } 603 604 #[test] 605 fn runtime_contract_output_matches_interactive_user_contract() { 606 let contract = runtime_contract_with_resolver( 607 &linux_resolver(), 608 RadrootsPathProfile::InteractiveUser, 609 None, 610 ) 611 .expect("interactive-user contract"); 612 613 assert_eq!(contract.active_profile, "interactive_user"); 614 assert_eq!(contract.path_overrides.profile_source, "caller"); 615 assert_eq!(contract.path_overrides.root_source, "host_defaults"); 616 assert_eq!(contract.path_overrides.repo_local_root, None); 617 assert_eq!(contract.path_overrides.repo_local_root_source, None); 618 assert_eq!( 619 contract.path_overrides.subordinate_path_override_source, 620 "config_artifact" 621 ); 622 assert_eq!( 623 contract.path_overrides.subordinate_path_override_keys, 624 vec![ 625 "logging.output_dir".to_owned(), 626 "subscriber.state.path".to_owned(), 627 ] 628 ); 629 assert_eq!( 630 contract.allowed_profiles, 631 vec![ 632 "interactive_user".to_owned(), 633 "service_host".to_owned(), 634 "repo_local".to_owned(), 635 ] 636 ); 637 assert_eq!(contract.default_shared_secret_backend, "encrypted_file"); 638 assert_eq!( 639 contract.allowed_shared_secret_backends, 640 vec!["encrypted_file".to_owned()] 641 ); 642 assert_eq!( 643 contract.migration.posture, 644 "explicit_operator_import_required" 645 ); 646 assert_eq!(contract.migration.state, "ready"); 647 assert_eq!(contract.migration.silent_startup_relocation, false); 648 assert_eq!( 649 contract.migration.compatibility_window, 650 "detect_and_report_only" 651 ); 652 assert!(contract.migration.detected_legacy_paths.is_empty()); 653 assert_eq!( 654 contract.canonical_config_path, 655 PathBuf::from("/home/treesap/.radroots/config/workers/rhi/config.toml") 656 ); 657 assert_eq!( 658 contract.canonical_logs_dir, 659 PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") 660 ); 661 assert_eq!( 662 contract.canonical_identity_path, 663 PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json") 664 ); 665 assert_eq!( 666 contract.canonical_subscriber_state_path, 667 PathBuf::from("/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json") 668 ); 669 } 670 671 #[test] 672 fn runtime_contract_output_matches_service_host_contract() { 673 let resolver = 674 RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); 675 let contract = 676 runtime_contract_with_resolver(&resolver, RadrootsPathProfile::ServiceHost, None) 677 .expect("service-host contract"); 678 679 assert_eq!(contract.active_profile, "service_host"); 680 assert_eq!( 681 contract.canonical_config_path, 682 PathBuf::from("/etc/radroots/workers/rhi/config.toml") 683 ); 684 assert_eq!( 685 contract.canonical_logs_dir, 686 PathBuf::from("/var/log/radroots/workers/rhi") 687 ); 688 assert_eq!( 689 contract.canonical_identity_path, 690 PathBuf::from("/etc/radroots/secrets/workers/rhi/identity.secret.json") 691 ); 692 assert_eq!( 693 contract.canonical_subscriber_state_path, 694 PathBuf::from("/var/lib/radroots/workers/rhi/trade-listing/state.json") 695 ); 696 } 697 }