main.rs (20073B)
1 #![cfg_attr(coverage_nightly, feature(coverage_attribute))] 2 3 #[cfg(not(test))] 4 use anyhow::Context; 5 use anyhow::Result; 6 #[cfg(not(test))] 7 use clap::Parser; 8 #[cfg(not(test))] 9 use radroots_log::{LogFileLayout, LoggingOptions}; 10 #[cfg(not(test))] 11 use rhi::cli::Command; 12 use rhi::{cli_args, config, paths, run_rhi}; 13 #[cfg(not(test))] 14 use rhi::{proof_smoke, remote_prove}; 15 use std::path::PathBuf; 16 use std::process::ExitCode; 17 use tracing::info; 18 19 #[cfg(not(test))] 20 #[tokio::main] 21 async fn main() -> ExitCode { 22 exit_code_from_run(run().await) 23 } 24 25 #[cfg(test)] 26 fn main() -> ExitCode { 27 exit_code_from_run(Ok(())) 28 } 29 30 fn exit_code_from_run(result: Result<()>) -> ExitCode { 31 match result { 32 Ok(()) => ExitCode::SUCCESS, 33 Err(err) => { 34 tracing::error!(error = ?err, "Fatal error"); 35 eprintln!("Fatal error: {err:#}"); 36 ExitCode::FAILURE 37 } 38 } 39 } 40 41 #[cfg(test)] 42 static RUN_LOAD_HOOK: std::sync::OnceLock< 43 std::sync::Mutex<Option<Result<(cli_args, config::Settings)>>>, 44 > = std::sync::OnceLock::new(); 45 46 #[cfg(test)] 47 fn run_load_hook() -> &'static std::sync::Mutex<Option<Result<(cli_args, config::Settings)>>> { 48 RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 49 } 50 51 #[derive(Debug, Clone, PartialEq, Eq)] 52 struct RhiRuntimeStartupReport { 53 active_profile: String, 54 config_path: PathBuf, 55 config_path_source: String, 56 canonical_config_path: PathBuf, 57 logs_dir: PathBuf, 58 logs_dir_source: String, 59 canonical_logs_dir: PathBuf, 60 identity_path: PathBuf, 61 identity_path_source: String, 62 canonical_identity_path: PathBuf, 63 subscriber_state_path: PathBuf, 64 subscriber_state_path_source: String, 65 canonical_subscriber_state_path: PathBuf, 66 path_overrides: paths::RhiRuntimePathOverrideContractOutput, 67 migration: paths::RhiRuntimeMigrationContractOutput, 68 default_shared_secret_backend: String, 69 allowed_shared_secret_backends: Vec<String>, 70 } 71 72 fn load_args_and_settings() -> Result<(cli_args, config::Settings)> { 73 #[cfg(test)] 74 { 75 if let Some(result) = run_load_hook() 76 .lock() 77 .unwrap_or_else(std::sync::PoisonError::into_inner) 78 .take() 79 { 80 return result; 81 } 82 return Err(anyhow::anyhow!("run loader hook not set")); 83 } 84 85 #[cfg(not(test))] 86 { 87 let args = cli_args::try_parse().map_err(radroots_runtime::RuntimeCliError::from)?; 88 let config_path = args 89 .service 90 .config 91 .clone() 92 .map(Ok) 93 .unwrap_or_else(paths::default_config_path_for_process)?; 94 let settings = 95 config::load_settings_from_path(&config_path).context("load configuration")?; 96 init_rhi_logging(&settings)?; 97 Ok((args, settings)) 98 } 99 } 100 101 #[cfg(not(test))] 102 fn init_rhi_logging(settings: &config::Settings) -> Result<()> { 103 radroots_log::init_logging(LoggingOptions { 104 dir: Some(settings.config.logging.output_dir.clone()), 105 file_name: "rhi.log".to_owned(), 106 stdout: settings.config.logging.stdout, 107 default_level: Some(settings.config.logging.filter.clone()), 108 file_layout: LogFileLayout::PrefixedDate, 109 }) 110 .context("initialize logging") 111 } 112 113 fn runtime_startup_report( 114 args: &cli_args, 115 settings: &config::Settings, 116 contract: &paths::RhiRuntimeContractOutput, 117 migration: paths::RhiRuntimeMigrationContractOutput, 118 ) -> RhiRuntimeStartupReport { 119 RhiRuntimeStartupReport { 120 active_profile: contract.active_profile.clone(), 121 config_path: args 122 .service 123 .config 124 .clone() 125 .unwrap_or_else(|| contract.canonical_config_path.clone()), 126 config_path_source: cli_or_profile_path_source( 127 args.service.config.is_some(), 128 &args 129 .service 130 .config 131 .clone() 132 .unwrap_or_else(|| contract.canonical_config_path.clone()), 133 &contract.canonical_config_path, 134 ), 135 canonical_config_path: contract.canonical_config_path.clone(), 136 logs_dir: settings.config.logging.output_dir.clone(), 137 logs_dir_source: config_or_profile_path_source( 138 &settings.config.logging.output_dir, 139 &contract.canonical_logs_dir, 140 ), 141 canonical_logs_dir: contract.canonical_logs_dir.clone(), 142 identity_path: args 143 .service 144 .identity 145 .clone() 146 .unwrap_or_else(|| contract.canonical_identity_path.clone()), 147 identity_path_source: cli_or_profile_path_source( 148 args.service.identity.is_some(), 149 &args 150 .service 151 .identity 152 .clone() 153 .unwrap_or_else(|| contract.canonical_identity_path.clone()), 154 &contract.canonical_identity_path, 155 ), 156 canonical_identity_path: contract.canonical_identity_path.clone(), 157 subscriber_state_path: settings.config.subscriber.state.path.clone(), 158 subscriber_state_path_source: config_or_profile_path_source( 159 &settings.config.subscriber.state.path, 160 &contract.canonical_subscriber_state_path, 161 ), 162 canonical_subscriber_state_path: contract.canonical_subscriber_state_path.clone(), 163 path_overrides: contract.path_overrides.clone(), 164 migration, 165 default_shared_secret_backend: contract.default_shared_secret_backend.clone(), 166 allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), 167 } 168 } 169 170 fn cli_or_profile_path_source( 171 is_cli_arg: bool, 172 actual_path: &PathBuf, 173 canonical_path: &PathBuf, 174 ) -> String { 175 if is_cli_arg { 176 "cli_arg".to_owned() 177 } else { 178 config_or_profile_path_source(actual_path, canonical_path) 179 } 180 } 181 182 fn config_or_profile_path_source(actual_path: &PathBuf, canonical_path: &PathBuf) -> String { 183 if actual_path == canonical_path { 184 "profile_default".to_owned() 185 } else { 186 "config_artifact".to_owned() 187 } 188 } 189 190 #[cfg(not(test))] 191 fn log_runtime_startup_report(report: &RhiRuntimeStartupReport) { 192 info!( 193 active_profile = report.active_profile.as_str(), 194 profile_source = report.path_overrides.profile_source.as_str(), 195 root_source = report.path_overrides.root_source.as_str(), 196 repo_local_root = ?report.path_overrides.repo_local_root, 197 repo_local_root_source = ?report.path_overrides.repo_local_root_source, 198 subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(), 199 migration_posture = report.migration.posture.as_str(), 200 migration_state = report.migration.state.as_str(), 201 migration_detected_legacy_paths = report.migration.detected_legacy_paths.len(), 202 silent_startup_relocation = report.migration.silent_startup_relocation, 203 config_path = %report.config_path.display(), 204 config_path_source = report.config_path_source.as_str(), 205 canonical_config_path = %report.canonical_config_path.display(), 206 logs_dir = %report.logs_dir.display(), 207 logs_dir_source = report.logs_dir_source.as_str(), 208 canonical_logs_dir = %report.canonical_logs_dir.display(), 209 identity_path = %report.identity_path.display(), 210 identity_path_source = report.identity_path_source.as_str(), 211 canonical_identity_path = %report.canonical_identity_path.display(), 212 subscriber_state_path = %report.subscriber_state_path.display(), 213 subscriber_state_path_source = report.subscriber_state_path_source.as_str(), 214 canonical_subscriber_state_path = %report.canonical_subscriber_state_path.display(), 215 default_shared_secret_backend = report.default_shared_secret_backend.as_str(), 216 allowed_shared_secret_backends = ?report.allowed_shared_secret_backends, 217 "rhi runtime contract" 218 ); 219 } 220 221 async fn run() -> Result<()> { 222 #[cfg(not(test))] 223 { 224 let args = cli_args::try_parse().map_err(radroots_runtime::RuntimeCliError::from)?; 225 if let Some(command) = args.command { 226 return match command { 227 Command::ProofSmoke { .. } => proof_smoke::run_cli_command(command).await, 228 Command::RemoteProve { .. } => remote_prove::run_cli_command(command).await, 229 }; 230 } 231 } 232 233 let (args, settings): (cli_args, config::Settings) = load_args_and_settings()?; 234 235 #[cfg(not(test))] 236 { 237 let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?; 238 let migration = 239 paths::runtime_migration_for_process(&contract).context("inspect runtime migration")?; 240 let report = runtime_startup_report(&args, &settings, &contract, migration); 241 log_runtime_startup_report(&report); 242 } 243 244 info!("Starting"); 245 246 run_rhi(&settings, &args).await 247 } 248 249 #[cfg(test)] 250 #[cfg_attr(coverage_nightly, coverage(off))] 251 mod tests { 252 use super::{ 253 RhiRuntimeStartupReport, exit_code_from_run, main, run, run_load_hook, run_rhi, 254 runtime_startup_report, 255 }; 256 use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrKeys}; 257 use rhi::features::trade_listing::state::TradeListingRuntime; 258 use rhi::{cli_args, config, paths}; 259 use std::path::PathBuf; 260 use std::process::ExitCode; 261 262 static RUN_HOOK_TEST_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = 263 std::sync::OnceLock::new(); 264 265 fn run_hook_test_lock() -> &'static std::sync::Mutex<()> { 266 RUN_HOOK_TEST_LOCK.get_or_init(|| std::sync::Mutex::new(())) 267 } 268 269 fn minimal_settings() -> config::Settings { 270 config::Settings { 271 metadata: serde_json::from_str(r#"{"name":"rhi-test"}"#).expect("metadata"), 272 config: config::Configuration { 273 service: radroots_runtime::RadrootsNostrServiceConfig { 274 logs_dir: std::env::temp_dir() 275 .join("rhi-test-logs") 276 .display() 277 .to_string(), 278 relays: Vec::new(), 279 nip89_identifier: Some("rhi".to_string()), 280 nip89_extra_tags: Vec::new(), 281 }, 282 logging: config::LoggingConfig { 283 output_dir: std::env::temp_dir().join("rhi-test-logs"), 284 filter: "info".to_string(), 285 stdout: true, 286 }, 287 subscriber: config::SubscriberConfig::default(), 288 trade_validation_receipt: 289 rhi::features::trade_validation_receipt::TradeValidationReceiptProverPolicy::default(), 290 }, 291 } 292 } 293 294 fn sample_runtime_contract() -> paths::RhiRuntimeContractOutput { 295 paths::RhiRuntimeContractOutput { 296 active_profile: "interactive_user".to_string(), 297 allowed_profiles: vec![ 298 "interactive_user".to_string(), 299 "service_host".to_string(), 300 "repo_local".to_string(), 301 ], 302 path_overrides: paths::RhiRuntimePathOverrideContractOutput { 303 profile_source: "caller".to_string(), 304 root_source: "host_defaults".to_string(), 305 repo_local_root: None, 306 repo_local_root_source: None, 307 subordinate_path_override_source: "config_artifact".to_string(), 308 subordinate_path_override_keys: vec![ 309 "logging.output_dir".to_string(), 310 "subscriber.state.path".to_string(), 311 ], 312 }, 313 default_shared_secret_backend: "encrypted_file".to_string(), 314 allowed_shared_secret_backends: vec!["encrypted_file".to_string()], 315 migration: paths::RhiRuntimeMigrationContractOutput { 316 posture: "explicit_operator_import_required".to_string(), 317 state: "ready".to_string(), 318 silent_startup_relocation: false, 319 compatibility_window: "detect_and_report_only".to_string(), 320 detected_legacy_paths: Vec::new(), 321 }, 322 canonical_config_path: PathBuf::from( 323 "/home/treesap/.radroots/config/workers/rhi/config.toml", 324 ), 325 canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/workers/rhi"), 326 canonical_identity_path: PathBuf::from( 327 "/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json", 328 ), 329 canonical_subscriber_state_path: PathBuf::from( 330 "/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json", 331 ), 332 } 333 } 334 335 #[test] 336 fn exit_code_from_run_maps_success_and_error() { 337 assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS); 338 assert_eq!( 339 exit_code_from_run(Err(anyhow::anyhow!("boom"))), 340 ExitCode::FAILURE 341 ); 342 } 343 344 #[tokio::test] 345 async fn run_rhi_returns_error_when_identity_is_missing() { 346 let args = cli_args { 347 command: None, 348 service: radroots_runtime::RadrootsServiceCliArgs { 349 config: Some(PathBuf::from("config.toml")), 350 identity: Some(PathBuf::from("/tmp/rhi-missing-identity.secret.json")), 351 allow_generate_identity: false, 352 }, 353 }; 354 let settings = minimal_settings(); 355 let err = run_rhi(&settings, &args) 356 .await 357 .expect_err("identity should fail"); 358 let msg = format!("{err:#}"); 359 assert!(msg.contains("identity")); 360 } 361 362 #[test] 363 fn main_returns_success_in_test_build() { 364 assert_eq!(main(), ExitCode::SUCCESS); 365 } 366 367 #[tokio::test] 368 async fn run_uses_injected_config_loader_result() { 369 let _guard = run_hook_test_lock() 370 .lock() 371 .unwrap_or_else(std::sync::PoisonError::into_inner); 372 let args = cli_args { 373 command: None, 374 service: radroots_runtime::RadrootsServiceCliArgs { 375 config: Some(PathBuf::from("config.toml")), 376 identity: Some(PathBuf::from("/tmp/rhi-run-hook-missing.secret.json")), 377 allow_generate_identity: false, 378 }, 379 }; 380 *run_load_hook() 381 .lock() 382 .unwrap_or_else(std::sync::PoisonError::into_inner) = 383 Some(Ok((args, minimal_settings()))); 384 let err = run().await.expect_err("missing identity should bubble"); 385 let msg = format!("{err:#}"); 386 assert!(msg.contains("identity")); 387 } 388 389 #[tokio::test] 390 async fn run_returns_error_when_loader_hook_is_absent() { 391 let _guard = run_hook_test_lock() 392 .lock() 393 .unwrap_or_else(std::sync::PoisonError::into_inner); 394 *run_load_hook() 395 .lock() 396 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 397 let err = run() 398 .await 399 .expect_err("loader hook should be required in test build"); 400 let msg = format!("{err:#}"); 401 assert!(msg.contains("run loader hook not set")); 402 } 403 404 #[tokio::test] 405 async fn non_test_start_subscriber_path_can_start_and_stop() { 406 let keys = RadrootsNostrKeys::generate(); 407 let client = RadrootsNostrClient::new(keys.clone()); 408 let handle = rhi::rhi::start_subscriber( 409 client, 410 keys, 411 TradeListingRuntime::new(), 412 radroots_runtime::BackoffConfig { 413 base_ms: 1, 414 max_ms: 2, 415 factor: 1, 416 jitter_ms: 0, 417 }, 418 ) 419 .await; 420 tokio::time::sleep(std::time::Duration::from_millis(20)).await; 421 handle.stop(); 422 handle.stopped().await; 423 } 424 425 #[test] 426 fn runtime_startup_report_prefers_explicit_cli_paths() { 427 let args = cli_args { 428 service: radroots_runtime::RadrootsServiceCliArgs { 429 config: Some(PathBuf::from("/tmp/rhi/config.toml")), 430 identity: Some(PathBuf::from("/tmp/rhi/identity.secret.json")), 431 allow_generate_identity: false, 432 }, 433 command: None, 434 }; 435 let mut settings = minimal_settings(); 436 settings.config.service.logs_dir = "/tmp/rhi/logs".to_string(); 437 settings.config.logging.output_dir = PathBuf::from("/tmp/rhi/logs"); 438 settings.config.subscriber.state.path = PathBuf::from("/tmp/rhi/state.json"); 439 440 let contract = sample_runtime_contract(); 441 let report = 442 runtime_startup_report(&args, &settings, &contract, contract.migration.clone()); 443 444 assert_eq!( 445 report, 446 RhiRuntimeStartupReport { 447 active_profile: "interactive_user".to_string(), 448 config_path: PathBuf::from("/tmp/rhi/config.toml"), 449 config_path_source: "cli_arg".to_string(), 450 canonical_config_path: PathBuf::from( 451 "/home/treesap/.radroots/config/workers/rhi/config.toml" 452 ), 453 logs_dir: PathBuf::from("/tmp/rhi/logs"), 454 logs_dir_source: "config_artifact".to_string(), 455 canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/workers/rhi"), 456 identity_path: PathBuf::from("/tmp/rhi/identity.secret.json"), 457 identity_path_source: "cli_arg".to_string(), 458 canonical_identity_path: PathBuf::from( 459 "/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json" 460 ), 461 subscriber_state_path: PathBuf::from("/tmp/rhi/state.json"), 462 subscriber_state_path_source: "config_artifact".to_string(), 463 canonical_subscriber_state_path: PathBuf::from( 464 "/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json" 465 ), 466 path_overrides: sample_runtime_contract().path_overrides, 467 migration: sample_runtime_contract().migration, 468 default_shared_secret_backend: "encrypted_file".to_string(), 469 allowed_shared_secret_backends: vec!["encrypted_file".to_string()], 470 } 471 ); 472 } 473 474 #[test] 475 fn runtime_startup_report_falls_back_to_canonical_contract_paths() { 476 let args = cli_args { 477 command: None, 478 service: radroots_runtime::RadrootsServiceCliArgs { 479 config: None, 480 identity: None, 481 allow_generate_identity: false, 482 }, 483 }; 484 let contract = sample_runtime_contract(); 485 let mut settings = minimal_settings(); 486 settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); 487 settings.config.logging.output_dir = contract.canonical_logs_dir.clone(); 488 settings.config.subscriber.state.path = contract.canonical_subscriber_state_path.clone(); 489 490 let report = 491 runtime_startup_report(&args, &settings, &contract, contract.migration.clone()); 492 493 assert_eq!(report.config_path, contract.canonical_config_path); 494 assert_eq!(report.config_path_source, "profile_default"); 495 assert_eq!(report.logs_dir, contract.canonical_logs_dir); 496 assert_eq!(report.logs_dir_source, "profile_default"); 497 assert_eq!(report.identity_path, contract.canonical_identity_path); 498 assert_eq!(report.identity_path_source, "profile_default"); 499 assert_eq!( 500 report.subscriber_state_path, 501 contract.canonical_subscriber_state_path 502 ); 503 assert_eq!(report.subscriber_state_path_source, "profile_default"); 504 assert_eq!(report.path_overrides, contract.path_overrides); 505 assert_eq!(report.migration, contract.migration); 506 assert_eq!(report.default_shared_secret_backend, "encrypted_file"); 507 assert_eq!( 508 report.allowed_shared_secret_backends, 509 vec!["encrypted_file".to_string()] 510 ); 511 } 512 }