rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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 }