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 }