rhi

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

paths.rs (14217B)


      1 use std::path::{Path, PathBuf};
      2 
      3 use anyhow::{Context, Result, bail};
      4 use radroots_runtime_paths::{
      5     DEFAULT_CONFIG_FILE_NAME, DEFAULT_SERVICE_IDENTITY_FILE_NAME, RadrootsLegacyPathCandidate,
      6     RadrootsMigrationReport, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver,
      7     RadrootsRuntimeNamespace, inspect_legacy_paths,
      8 };
      9 use serde::Serialize;
     10 
     11 const RHI_RUNTIME_ID: &str = "rhi";
     12 const SUBSCRIBER_STATE_DIR_NAME: &str = "trade-listing";
     13 const SUBSCRIBER_STATE_FILE_NAME: &str = "state.json";
     14 const RHI_PATHS_PROFILE_ENV: &str = "RHI_PATHS_PROFILE";
     15 const RHI_PATHS_REPO_LOCAL_ROOT_ENV: &str = "RHI_PATHS_REPO_LOCAL_ROOT";
     16 const RHI_DEFAULT_SHARED_SECRET_BACKEND: &str = "encrypted_file";
     17 const RHI_ALLOWED_PROFILES: [&str; 3] = ["interactive_user", "service_host", "repo_local"];
     18 const RHI_ALLOWED_SHARED_SECRET_BACKENDS: [&str; 1] = ["encrypted_file"];
     19 const SUBORDINATE_PATH_OVERRIDE_SOURCE: &str = "config_artifact";
     20 const SUBORDINATE_PATH_OVERRIDE_KEYS: [&str; 2] = ["logging.output_dir", "subscriber.state.path"];
     21 const MIGRATION_IMPORT_HINT: &str = "stop the worker, inspect this legacy path, then perform an explicit import or manual copy into the canonical destination; rhi will not move it on startup";
     22 
     23 #[derive(Debug, Clone, PartialEq, Eq)]
     24 pub(crate) struct RhiRuntimePaths {
     25     pub(crate) config_path: PathBuf,
     26     pub(crate) logs_dir: PathBuf,
     27     pub(crate) identity_path: PathBuf,
     28     pub(crate) subscriber_state_path: PathBuf,
     29 }
     30 
     31 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     32 pub struct RhiRuntimeContractOutput {
     33     pub active_profile: String,
     34     pub allowed_profiles: Vec<String>,
     35     pub path_overrides: RhiRuntimePathOverrideContractOutput,
     36     pub default_shared_secret_backend: String,
     37     pub allowed_shared_secret_backends: Vec<String>,
     38     pub migration: RhiRuntimeMigrationContractOutput,
     39     pub canonical_config_path: PathBuf,
     40     pub canonical_logs_dir: PathBuf,
     41     pub canonical_identity_path: PathBuf,
     42     pub canonical_subscriber_state_path: PathBuf,
     43 }
     44 
     45 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     46 pub struct RhiRuntimeMigrationContractOutput {
     47     pub posture: String,
     48     pub state: String,
     49     pub silent_startup_relocation: bool,
     50     pub compatibility_window: String,
     51     pub detected_legacy_paths: Vec<RhiRuntimeLegacyPathOutput>,
     52 }
     53 
     54 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     55 pub struct RhiRuntimeLegacyPathOutput {
     56     pub id: String,
     57     pub description: String,
     58     pub path: PathBuf,
     59     #[serde(skip_serializing_if = "Option::is_none")]
     60     pub destination: Option<PathBuf>,
     61     pub import_hint: String,
     62 }
     63 
     64 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     65 pub struct RhiRuntimePathOverrideContractOutput {
     66     pub profile_source: String,
     67     pub root_source: String,
     68     #[serde(skip_serializing_if = "Option::is_none")]
     69     pub repo_local_root: Option<PathBuf>,
     70     #[serde(skip_serializing_if = "Option::is_none")]
     71     pub repo_local_root_source: Option<String>,
     72     pub subordinate_path_override_source: String,
     73     pub subordinate_path_override_keys: Vec<String>,
     74 }
     75 
     76 struct RhiRuntimePathSelection {
     77     profile: RadrootsPathProfile,
     78     profile_source: String,
     79     repo_local_root: Option<PathBuf>,
     80     repo_local_root_source: Option<String>,
     81 }
     82 
     83 fn parse_path_profile(value: &str) -> Result<RadrootsPathProfile> {
     84     match value {
     85         "interactive_user" => Ok(RadrootsPathProfile::InteractiveUser),
     86         "service_host" => Ok(RadrootsPathProfile::ServiceHost),
     87         "repo_local" => Ok(RadrootsPathProfile::RepoLocal),
     88         _ => bail!(
     89             "{RHI_PATHS_PROFILE_ENV} must be `interactive_user`, `service_host`, or `repo_local`"
     90         ),
     91     }
     92 }
     93 
     94 pub(crate) fn process_path_selection() -> Result<(RadrootsPathProfile, Option<PathBuf>)> {
     95     let selection = process_path_selection_with_sources()?;
     96     Ok((selection.profile, selection.repo_local_root))
     97 }
     98 
     99 fn process_path_selection_with_sources() -> Result<RhiRuntimePathSelection> {
    100     let profile = match std::env::var(RHI_PATHS_PROFILE_ENV) {
    101         Ok(value) => (
    102             parse_path_profile(&value)?,
    103             format!("process_env:{RHI_PATHS_PROFILE_ENV}"),
    104         ),
    105         Err(std::env::VarError::NotPresent) => {
    106             (RadrootsPathProfile::InteractiveUser, "default".to_owned())
    107         }
    108         Err(std::env::VarError::NotUnicode(_)) => {
    109             bail!("{RHI_PATHS_PROFILE_ENV} must be valid utf-8 when set")
    110         }
    111     };
    112     let repo_local_root_raw = std::env::var_os(RHI_PATHS_REPO_LOCAL_ROOT_ENV);
    113     let repo_local_root = repo_local_root_raw.as_ref().map(PathBuf::from);
    114     Ok(RhiRuntimePathSelection {
    115         profile: profile.0,
    116         profile_source: profile.1,
    117         repo_local_root,
    118         repo_local_root_source: repo_local_root_raw
    119             .as_ref()
    120             .map(|_| format!("process_env:{RHI_PATHS_REPO_LOCAL_ROOT_ENV}")),
    121     })
    122 }
    123 
    124 fn path_overrides_for(
    125     profile: RadrootsPathProfile,
    126     repo_local_root: Option<&Path>,
    127 ) -> Result<RadrootsPathOverrides> {
    128     match profile {
    129         RadrootsPathProfile::RepoLocal => {
    130             let repo_local_root = repo_local_root.context(format!(
    131                 "{RHI_PATHS_REPO_LOCAL_ROOT_ENV} must be set when {RHI_PATHS_PROFILE_ENV}=repo_local"
    132             ))?;
    133             Ok(RadrootsPathOverrides::repo_local(repo_local_root))
    134         }
    135         _ => Ok(RadrootsPathOverrides::default()),
    136     }
    137 }
    138 
    139 pub(crate) fn resolve_runtime_paths_with_resolver(
    140     resolver: &RadrootsPathResolver,
    141     profile: RadrootsPathProfile,
    142     repo_local_root: Option<&Path>,
    143 ) -> Result<RhiRuntimePaths> {
    144     let namespace = RadrootsRuntimeNamespace::worker(RHI_RUNTIME_ID)
    145         .map_err(|error| anyhow::anyhow!("resolve rhi namespace: {error}"))?;
    146     let overrides = path_overrides_for(profile, repo_local_root)?;
    147     let namespaced = resolver
    148         .resolve(profile, &overrides)
    149         .map_err(|error| anyhow::anyhow!("resolve rhi runtime paths: {error}"))?
    150         .namespaced(&namespace);
    151     Ok(RhiRuntimePaths {
    152         config_path: namespaced.config.join(DEFAULT_CONFIG_FILE_NAME),
    153         logs_dir: namespaced.logs,
    154         identity_path: namespaced.secrets.join(DEFAULT_SERVICE_IDENTITY_FILE_NAME),
    155         subscriber_state_path: namespaced
    156             .data
    157             .join(SUBSCRIBER_STATE_DIR_NAME)
    158             .join(SUBSCRIBER_STATE_FILE_NAME),
    159     })
    160 }
    161 
    162 pub(crate) fn default_runtime_paths_for_process() -> Result<RhiRuntimePaths> {
    163     let (profile, repo_local_root) = process_path_selection()?;
    164     resolve_runtime_paths_with_resolver(
    165         &RadrootsPathResolver::current(),
    166         profile,
    167         repo_local_root.as_deref(),
    168     )
    169 }
    170 
    171 pub fn default_config_path_for_process() -> Result<PathBuf> {
    172     Ok(default_runtime_paths_for_process()?.config_path)
    173 }
    174 
    175 pub fn default_identity_path_for_process() -> Result<PathBuf> {
    176     Ok(default_runtime_paths_for_process()?.identity_path)
    177 }
    178 
    179 pub fn default_subscriber_state_path_for_process() -> Result<PathBuf> {
    180     Ok(default_runtime_paths_for_process()?.subscriber_state_path)
    181 }
    182 
    183 pub fn runtime_contract_for_process() -> Result<RhiRuntimeContractOutput> {
    184     let selection = process_path_selection_with_sources()?;
    185     runtime_contract_with_selection(&RadrootsPathResolver::current(), &selection)
    186 }
    187 
    188 #[cfg(test)]
    189 pub(crate) fn runtime_contract_with_resolver(
    190     resolver: &RadrootsPathResolver,
    191     profile: RadrootsPathProfile,
    192     repo_local_root: Option<&Path>,
    193 ) -> Result<RhiRuntimeContractOutput> {
    194     runtime_contract_with_selection(
    195         resolver,
    196         &RhiRuntimePathSelection {
    197             profile,
    198             profile_source: "caller".to_owned(),
    199             repo_local_root: repo_local_root.map(Path::to_path_buf),
    200             repo_local_root_source: repo_local_root.map(|_| "caller".to_owned()),
    201         },
    202     )
    203 }
    204 
    205 fn runtime_contract_with_selection(
    206     resolver: &RadrootsPathResolver,
    207     selection: &RhiRuntimePathSelection,
    208 ) -> Result<RhiRuntimeContractOutput> {
    209     let profile = selection.profile;
    210     let repo_local_root = selection.repo_local_root.as_deref();
    211     let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?;
    212     Ok(RhiRuntimeContractOutput {
    213         active_profile: profile.to_string(),
    214         allowed_profiles: RHI_ALLOWED_PROFILES
    215             .into_iter()
    216             .map(str::to_owned)
    217             .collect(),
    218         path_overrides: RhiRuntimePathOverrideContractOutput {
    219             profile_source: selection.profile_source.clone(),
    220             root_source: root_source_for_profile(profile).to_owned(),
    221             repo_local_root: selection.repo_local_root.clone(),
    222             repo_local_root_source: selection.repo_local_root_source.clone(),
    223             subordinate_path_override_source: SUBORDINATE_PATH_OVERRIDE_SOURCE.to_owned(),
    224             subordinate_path_override_keys: SUBORDINATE_PATH_OVERRIDE_KEYS
    225                 .into_iter()
    226                 .map(str::to_owned)
    227                 .collect(),
    228         },
    229         default_shared_secret_backend: RHI_DEFAULT_SHARED_SECRET_BACKEND.to_owned(),
    230         allowed_shared_secret_backends: RHI_ALLOWED_SHARED_SECRET_BACKENDS
    231             .into_iter()
    232             .map(str::to_owned)
    233             .collect(),
    234         migration: migration_contract_output(RadrootsMigrationReport::empty()),
    235         canonical_config_path: paths.config_path,
    236         canonical_logs_dir: paths.logs_dir,
    237         canonical_identity_path: paths.identity_path,
    238         canonical_subscriber_state_path: paths.subscriber_state_path,
    239     })
    240 }
    241 
    242 pub fn runtime_migration_for_process(
    243     contract: &RhiRuntimeContractOutput,
    244 ) -> Result<RhiRuntimeMigrationContractOutput> {
    245     let current_dir = std::env::current_dir().context("resolve current directory")?;
    246     Ok(runtime_migration_for_current_dir(
    247         contract,
    248         current_dir.as_path(),
    249     ))
    250 }
    251 
    252 pub(crate) fn runtime_migration_for_current_dir(
    253     contract: &RhiRuntimeContractOutput,
    254     current_dir: &Path,
    255 ) -> RhiRuntimeMigrationContractOutput {
    256     let report = inspect_legacy_paths(legacy_path_candidates(contract, current_dir));
    257     migration_contract_output(report)
    258 }
    259 
    260 fn legacy_path_candidates(
    261     contract: &RhiRuntimeContractOutput,
    262     current_dir: &Path,
    263 ) -> Vec<RadrootsLegacyPathCandidate> {
    264     vec![
    265         RadrootsLegacyPathCandidate::new(
    266             "rhi_repo_config_v0",
    267             "legacy rhi repo-relative config",
    268             current_dir.join(DEFAULT_CONFIG_FILE_NAME),
    269             Some(contract.canonical_config_path.clone()),
    270             MIGRATION_IMPORT_HINT,
    271         ),
    272         RadrootsLegacyPathCandidate::new(
    273             "rhi_repo_logs_v0",
    274             "legacy rhi repo-relative logs directory",
    275             current_dir.join("logs"),
    276             Some(contract.canonical_logs_dir.clone()),
    277             MIGRATION_IMPORT_HINT,
    278         ),
    279         RadrootsLegacyPathCandidate::new(
    280             "rhi_repo_subscriber_state_v0",
    281             "legacy rhi repo-relative subscriber state",
    282             current_dir.join("state/trade-listing-state.json"),
    283             Some(contract.canonical_subscriber_state_path.clone()),
    284             MIGRATION_IMPORT_HINT,
    285         ),
    286     ]
    287 }
    288 
    289 fn migration_contract_output(report: RadrootsMigrationReport) -> RhiRuntimeMigrationContractOutput {
    290     RhiRuntimeMigrationContractOutput {
    291         posture: report.posture.to_owned(),
    292         state: report.state.to_owned(),
    293         silent_startup_relocation: report.silent_startup_relocation,
    294         compatibility_window: report.compatibility_window.to_owned(),
    295         detected_legacy_paths: report
    296             .detected_legacy_paths
    297             .into_iter()
    298             .map(|path| RhiRuntimeLegacyPathOutput {
    299                 id: path.id,
    300                 description: path.description,
    301                 path: path.path,
    302                 destination: path.destination,
    303                 import_hint: path.import_hint,
    304             })
    305             .collect(),
    306     }
    307 }
    308 
    309 fn root_source_for_profile(profile: RadrootsPathProfile) -> &'static str {
    310     match profile {
    311         RadrootsPathProfile::InteractiveUser => "host_defaults",
    312         RadrootsPathProfile::ServiceHost => "service_host_defaults",
    313         RadrootsPathProfile::RepoLocal => "repo_local_root",
    314         RadrootsPathProfile::MobileNative => "mobile_native_defaults",
    315     }
    316 }
    317 
    318 #[cfg(test)]
    319 mod tests {
    320     use std::path::PathBuf;
    321 
    322     use radroots_runtime_paths::{
    323         RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform,
    324     };
    325 
    326     use super::{runtime_contract_with_resolver, runtime_migration_for_current_dir};
    327 
    328     fn linux_resolver() -> RadrootsPathResolver {
    329         RadrootsPathResolver::new(
    330             RadrootsPlatform::Linux,
    331             RadrootsHostEnvironment {
    332                 home_dir: Some(PathBuf::from("/home/treesap")),
    333                 ..RadrootsHostEnvironment::default()
    334             },
    335         )
    336     }
    337 
    338     #[test]
    339     fn runtime_migration_detects_legacy_repo_relative_state_without_moving_it() {
    340         let temp = tempfile::tempdir().expect("tempdir");
    341         std::fs::write(
    342             temp.path().join("config.toml"),
    343             "[metadata]\nname = \"old\"\n",
    344         )
    345         .expect("write old config");
    346         std::fs::create_dir_all(temp.path().join("state")).expect("state dir");
    347         std::fs::write(temp.path().join("state/trade-listing-state.json"), "{}")
    348             .expect("write old subscriber state");
    349         let contract = runtime_contract_with_resolver(
    350             &linux_resolver(),
    351             RadrootsPathProfile::InteractiveUser,
    352             None,
    353         )
    354         .expect("contract");
    355 
    356         let report = runtime_migration_for_current_dir(&contract, temp.path());
    357 
    358         assert_eq!(report.posture, "explicit_operator_import_required");
    359         assert_eq!(report.state, "legacy_state_detected");
    360         assert!(!report.silent_startup_relocation);
    361         assert_eq!(report.detected_legacy_paths.len(), 2);
    362         assert_eq!(report.detected_legacy_paths[0].id, "rhi_repo_config_v0");
    363         assert_eq!(
    364             report.detected_legacy_paths[1].id,
    365             "rhi_repo_subscriber_state_v0"
    366         );
    367         assert_eq!(
    368             report.detected_legacy_paths[1].destination,
    369             Some(contract.canonical_subscriber_state_path)
    370         );
    371     }
    372 }