rhi

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

commit dd68ec93b38e0acbfa8f0a33afa94d5e0fe8d4aa
parent 5281709783b28fd77eafb53e3ba8c61e90b84705
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Apr 2026 17:13:52 +0000

config: align runtime contract surfaces

Diffstat:
Mconfig.toml | 11+++++++++++
Msrc/config.rs | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 298 insertions(+), 3 deletions(-)

diff --git a/config.toml b/config.toml @@ -17,6 +17,17 @@ name = "rhi" # # Manual operator runs may instead place this file at: # ~/.radroots/config/workers/rhi/config.toml +# when path-like values are omitted, the active profile derives: +# interactive_user: +# logs_dir = ~/.radroots/logs/workers/rhi +# worker identity = ~/.radroots/secrets/workers/rhi/identity.secret.json +# subscriber state = ~/.radroots/data/workers/rhi/trade-listing/state.json +# service_host: +# logs_dir = /var/log/radroots/workers/rhi +# worker identity = /etc/radroots/secrets/workers/rhi/identity.secret.json +# subscriber state = /var/lib/radroots/workers/rhi/trade-listing/state.json +# the canonical live worker identity is always an encrypted local envelope +# only override logs_dir or config.subscriber.state.path intentionally relays = [ "ws://127.0.0.1:8080" ] diff --git a/src/config.rs b/src/config.rs @@ -13,6 +13,9 @@ const SUBSCRIBER_STATE_DIR_NAME: &str = "trade-listing"; const SUBSCRIBER_STATE_FILE_NAME: &str = "state.json"; const RHI_PATHS_PROFILE_ENV: &str = "RHI_PATHS_PROFILE"; const RHI_PATHS_REPO_LOCAL_ROOT_ENV: &str = "RHI_PATHS_REPO_LOCAL_ROOT"; +const RHI_DEFAULT_SHARED_SECRET_BACKEND: &str = "encrypted_file"; +const RHI_ALLOWED_PROFILES: [&str; 3] = ["interactive_user", "service_host", "repo_local"]; +const RHI_ALLOWED_SHARED_SECRET_BACKENDS: [&str; 1] = ["encrypted_file"]; fn default_replay_window_secs() -> u64 { 24 * 60 * 60 @@ -30,6 +33,18 @@ struct RhiRuntimePaths { subscriber_state_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RhiRuntimeContractOutput { + pub active_profile: String, + pub allowed_profiles: Vec<String>, + pub default_shared_secret_backend: String, + pub allowed_shared_secret_backends: Vec<String>, + pub canonical_config_path: PathBuf, + pub canonical_logs_dir: PathBuf, + pub canonical_identity_path: PathBuf, + pub canonical_subscriber_state_path: PathBuf, +} + #[derive(Debug, Deserialize, Clone, Default)] struct RawServiceConfig { #[serde(default)] @@ -252,6 +267,39 @@ pub fn default_subscriber_state_path_for_process() -> Result<PathBuf> { Ok(default_runtime_paths_for_process()?.subscriber_state_path) } +pub fn runtime_contract_for_process() -> Result<RhiRuntimeContractOutput> { + let (profile, repo_local_root) = process_path_selection()?; + runtime_contract_with_resolver( + &RadrootsPathResolver::current(), + profile, + repo_local_root.as_deref(), + ) +} + +fn runtime_contract_with_resolver( + resolver: &RadrootsPathResolver, + profile: RadrootsPathProfile, + repo_local_root: Option<&Path>, +) -> Result<RhiRuntimeContractOutput> { + let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?; + Ok(RhiRuntimeContractOutput { + active_profile: profile.to_string(), + allowed_profiles: RHI_ALLOWED_PROFILES + .into_iter() + .map(str::to_owned) + .collect(), + default_shared_secret_backend: RHI_DEFAULT_SHARED_SECRET_BACKEND.to_owned(), + allowed_shared_secret_backends: RHI_ALLOWED_SHARED_SECRET_BACKENDS + .into_iter() + .map(str::to_owned) + .collect(), + canonical_config_path: paths.config_path, + canonical_logs_dir: paths.logs_dir, + canonical_identity_path: paths.identity_path, + canonical_subscriber_state_path: paths.subscriber_state_path, + }) +} + fn load_settings_from_path_with_resolver( path: &Path, resolver: &RadrootsPathResolver, @@ -280,7 +328,7 @@ pub fn load_settings_from_path(path: &Path) -> Result<Settings> { mod tests { use super::{ default_subscriber_state_path_for_process, load_settings_from_path_with_resolver, - resolve_runtime_paths_with_resolver, + resolve_runtime_paths_with_resolver, runtime_contract_with_resolver, }; use radroots_runtime_paths::{ RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, @@ -452,4 +500,72 @@ replay_overlap_secs = 45 default_subscriber_state_path_for_process().expect("resolve current process defaults"); assert!(path.ends_with("trade-listing/state.json")); } + + #[test] + fn runtime_contract_output_matches_interactive_user_contract() { + let contract = runtime_contract_with_resolver( + &linux_resolver(), + RadrootsPathProfile::InteractiveUser, + None, + ) + .expect("interactive-user contract"); + + assert_eq!(contract.active_profile, "interactive_user"); + assert_eq!( + contract.allowed_profiles, + vec![ + "interactive_user".to_owned(), + "service_host".to_owned(), + "repo_local".to_owned(), + ] + ); + assert_eq!(contract.default_shared_secret_backend, "encrypted_file"); + assert_eq!( + contract.allowed_shared_secret_backends, + vec!["encrypted_file".to_owned()] + ); + assert_eq!( + contract.canonical_config_path, + PathBuf::from("/home/treesap/.radroots/config/workers/rhi/config.toml") + ); + assert_eq!( + contract.canonical_logs_dir, + PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") + ); + assert_eq!( + contract.canonical_identity_path, + PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json") + ); + assert_eq!( + contract.canonical_subscriber_state_path, + PathBuf::from("/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json") + ); + } + + #[test] + fn runtime_contract_output_matches_service_host_contract() { + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let contract = + runtime_contract_with_resolver(&resolver, RadrootsPathProfile::ServiceHost, None) + .expect("service-host contract"); + + assert_eq!(contract.active_profile, "service_host"); + assert_eq!( + contract.canonical_config_path, + PathBuf::from("/etc/radroots/workers/rhi/config.toml") + ); + assert_eq!( + contract.canonical_logs_dir, + PathBuf::from("/var/log/radroots/workers/rhi") + ); + assert_eq!( + contract.canonical_identity_path, + PathBuf::from("/etc/radroots/secrets/workers/rhi/identity.secret.json") + ); + assert_eq!( + contract.canonical_subscriber_state_path, + PathBuf::from("/var/lib/radroots/workers/rhi/trade-listing/state.json") + ); + } } diff --git a/src/main.rs b/src/main.rs @@ -6,6 +6,7 @@ use anyhow::Result; #[cfg(not(test))] use clap::Parser; use rhi::{cli_args, config, run_rhi}; +use std::path::PathBuf; use std::process::ExitCode; use tracing::info; @@ -41,6 +42,21 @@ fn run_load_hook() -> &'static std::sync::Mutex<Option<Result<(cli_args, config: RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None)) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RhiRuntimeStartupReport { + active_profile: String, + config_path: PathBuf, + canonical_config_path: PathBuf, + logs_dir: PathBuf, + canonical_logs_dir: PathBuf, + identity_path: PathBuf, + canonical_identity_path: PathBuf, + subscriber_state_path: PathBuf, + canonical_subscriber_state_path: PathBuf, + default_shared_secret_backend: String, + allowed_shared_secret_backends: Vec<String>, +} + fn load_args_and_settings() -> Result<(cli_args, config::Settings)> { #[cfg(test)] { @@ -65,14 +81,71 @@ fn load_args_and_settings() -> Result<(cli_args, config::Settings)> { .unwrap_or_else(config::default_config_path_for_process)?; let settings = config::load_settings_from_path(&config_path).context("load configuration")?; - radroots_runtime::init_with(settings.config.service.logs_dir.as_str(), None)?; + radroots_runtime::init_with_logs_dir( + std::path::Path::new(settings.config.service.logs_dir.as_str()), + None, + )?; Ok((args, settings)) } } +fn runtime_startup_report( + args: &cli_args, + settings: &config::Settings, + contract: &config::RhiRuntimeContractOutput, +) -> RhiRuntimeStartupReport { + RhiRuntimeStartupReport { + active_profile: contract.active_profile.clone(), + config_path: args + .service + .config + .clone() + .unwrap_or_else(|| contract.canonical_config_path.clone()), + canonical_config_path: contract.canonical_config_path.clone(), + logs_dir: PathBuf::from(settings.config.service.logs_dir.as_str()), + canonical_logs_dir: contract.canonical_logs_dir.clone(), + identity_path: args + .service + .identity + .clone() + .unwrap_or_else(|| contract.canonical_identity_path.clone()), + canonical_identity_path: contract.canonical_identity_path.clone(), + subscriber_state_path: settings.config.subscriber.state.path.clone(), + canonical_subscriber_state_path: contract.canonical_subscriber_state_path.clone(), + default_shared_secret_backend: contract.default_shared_secret_backend.clone(), + allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), + } +} + +#[cfg(not(test))] +fn log_runtime_startup_report(report: &RhiRuntimeStartupReport) { + info!( + active_profile = report.active_profile.as_str(), + config_path = %report.config_path.display(), + canonical_config_path = %report.canonical_config_path.display(), + logs_dir = %report.logs_dir.display(), + canonical_logs_dir = %report.canonical_logs_dir.display(), + identity_path = %report.identity_path.display(), + canonical_identity_path = %report.canonical_identity_path.display(), + subscriber_state_path = %report.subscriber_state_path.display(), + canonical_subscriber_state_path = %report.canonical_subscriber_state_path.display(), + default_shared_secret_backend = report.default_shared_secret_backend.as_str(), + allowed_shared_secret_backends = ?report.allowed_shared_secret_backends, + "rhi runtime contract" + ); +} + async fn run() -> Result<()> { let (args, settings): (cli_args, config::Settings) = load_args_and_settings()?; + #[cfg(not(test))] + { + let contract = + config::runtime_contract_for_process().context("resolve runtime contract")?; + let report = runtime_startup_report(&args, &settings, &contract); + log_runtime_startup_report(&report); + } + info!("Starting"); run_rhi(&settings, &args).await @@ -81,7 +154,10 @@ async fn run() -> Result<()> { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use super::{exit_code_from_run, main, run, run_load_hook, run_rhi}; + use super::{ + RhiRuntimeStartupReport, exit_code_from_run, main, run, run_load_hook, run_rhi, + runtime_startup_report, + }; use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrKeys}; use rhi::features::trade_listing::state::TradeListingRuntime; use rhi::{cli_args, config}; @@ -106,6 +182,29 @@ mod tests { } } + fn sample_runtime_contract() -> config::RhiRuntimeContractOutput { + config::RhiRuntimeContractOutput { + active_profile: "interactive_user".to_string(), + allowed_profiles: vec![ + "interactive_user".to_string(), + "service_host".to_string(), + "repo_local".to_string(), + ], + default_shared_secret_backend: "encrypted_file".to_string(), + allowed_shared_secret_backends: vec!["encrypted_file".to_string()], + canonical_config_path: PathBuf::from( + "/home/treesap/.radroots/config/workers/rhi/config.toml", + ), + canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/workers/rhi"), + canonical_identity_path: PathBuf::from( + "/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json", + ), + canonical_subscriber_state_path: PathBuf::from( + "/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json", + ), + } + } + #[test] fn exit_code_from_run_maps_success_and_error() { assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS); @@ -187,4 +286,73 @@ mod tests { handle.stop(); handle.stopped().await; } + + #[test] + fn runtime_startup_report_prefers_explicit_cli_paths() { + let args = cli_args { + service: radroots_runtime::RadrootsServiceCliArgs { + config: Some(PathBuf::from("/tmp/rhi/config.toml")), + identity: Some(PathBuf::from("/tmp/rhi/identity.secret.json")), + allow_generate_identity: false, + }, + }; + let mut settings = minimal_settings(); + settings.config.service.logs_dir = "/tmp/rhi/logs".to_string(); + settings.config.subscriber.state.path = PathBuf::from("/tmp/rhi/state.json"); + + let report = runtime_startup_report(&args, &settings, &sample_runtime_contract()); + + assert_eq!( + report, + RhiRuntimeStartupReport { + active_profile: "interactive_user".to_string(), + config_path: PathBuf::from("/tmp/rhi/config.toml"), + canonical_config_path: PathBuf::from( + "/home/treesap/.radroots/config/workers/rhi/config.toml" + ), + logs_dir: PathBuf::from("/tmp/rhi/logs"), + canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/workers/rhi"), + identity_path: PathBuf::from("/tmp/rhi/identity.secret.json"), + canonical_identity_path: PathBuf::from( + "/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json" + ), + subscriber_state_path: PathBuf::from("/tmp/rhi/state.json"), + canonical_subscriber_state_path: PathBuf::from( + "/home/treesap/.radroots/data/workers/rhi/trade-listing/state.json" + ), + default_shared_secret_backend: "encrypted_file".to_string(), + allowed_shared_secret_backends: vec!["encrypted_file".to_string()], + } + ); + } + + #[test] + fn runtime_startup_report_falls_back_to_canonical_contract_paths() { + let args = cli_args { + service: radroots_runtime::RadrootsServiceCliArgs { + config: None, + identity: None, + allow_generate_identity: false, + }, + }; + let contract = sample_runtime_contract(); + let mut settings = minimal_settings(); + settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); + settings.config.subscriber.state.path = contract.canonical_subscriber_state_path.clone(); + + let report = runtime_startup_report(&args, &settings, &contract); + + assert_eq!(report.config_path, contract.canonical_config_path); + assert_eq!(report.logs_dir, contract.canonical_logs_dir); + assert_eq!(report.identity_path, contract.canonical_identity_path); + assert_eq!( + report.subscriber_state_path, + contract.canonical_subscriber_state_path + ); + assert_eq!(report.default_shared_secret_backend, "encrypted_file"); + assert_eq!( + report.allowed_shared_secret_backends, + vec!["encrypted_file".to_string()] + ); + } }