commit dd68ec93b38e0acbfa8f0a33afa94d5e0fe8d4aa
parent 5281709783b28fd77eafb53e3ba8c61e90b84705
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Apr 2026 17:13:52 +0000
config: align runtime contract surfaces
Diffstat:
| M | config.toml | | | 11 | +++++++++++ |
| M | src/config.rs | | | 118 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/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()]
+ );
+ }
}