radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit c9977d64d340f6802a4b20ebc979ba9410b2178b
parent 1265c34e656243fb3288d463f45c2e9ab7eb5563
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 17:18:30 +0000

runtime: detect legacy startup paths

Diffstat:
Msrc/app/config.rs | 11+++++++++++
Msrc/app/paths.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app/runtime.rs | 27++++++++++++++++++++++++---
3 files changed, 188 insertions(+), 5 deletions(-)

diff --git a/src/app/config.rs b/src/app/config.rs @@ -671,6 +671,17 @@ bearer_token = "change-me" vec!["encrypted_file".to_owned()] ); assert_eq!( + contract.migration.posture, + "explicit_operator_import_required" + ); + assert_eq!(contract.migration.state, "ready"); + assert_eq!(contract.migration.silent_startup_relocation, false); + assert_eq!( + contract.migration.compatibility_window, + "detect_and_report_only" + ); + assert!(contract.migration.detected_legacy_paths.is_empty()); + assert_eq!( contract.canonical_config_path, PathBuf::from("/home/treesap/.radroots/config/services/radrootsd/config.toml") ); diff --git a/src/app/paths.rs b/src/app/paths.rs @@ -2,8 +2,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use radroots_runtime_paths::{ - DEFAULT_CONFIG_FILE_NAME, DEFAULT_SERVICE_IDENTITY_FILE_NAME, RadrootsPathOverrides, - RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace, + DEFAULT_CONFIG_FILE_NAME, DEFAULT_SERVICE_IDENTITY_FILE_NAME, RadrootsLegacyPathCandidate, + RadrootsMigrationReport, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, + RadrootsRuntimeNamespace, inspect_legacy_paths, }; use serde::Serialize; @@ -18,6 +19,7 @@ const RADROOTSD_ALLOWED_SHARED_SECRET_BACKENDS: [&str; 1] = ["encrypted_file"]; const SUBORDINATE_PATH_OVERRIDE_SOURCE: &str = "config_artifact"; const SUBORDINATE_PATH_OVERRIDE_KEYS: [&str; 2] = ["config.service.logs_dir", "config.bridge.state_path"]; +const MIGRATION_IMPORT_HINT: &str = "stop the runtime, inspect this legacy path, then perform an explicit import or manual copy into the canonical destination; radrootsd will not move it on startup"; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RadrootsdRuntimePaths { @@ -34,6 +36,7 @@ pub struct RadrootsdRuntimeContractOutput { pub path_overrides: RadrootsdRuntimePathOverrideContractOutput, pub default_shared_secret_backend: String, pub allowed_shared_secret_backends: Vec<String>, + pub migration: RadrootsdRuntimeMigrationContractOutput, pub canonical_config_path: PathBuf, pub canonical_logs_dir: PathBuf, pub canonical_identity_path: PathBuf, @@ -41,6 +44,25 @@ pub struct RadrootsdRuntimeContractOutput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RadrootsdRuntimeMigrationContractOutput { + pub posture: String, + pub state: String, + pub silent_startup_relocation: bool, + pub compatibility_window: String, + pub detected_legacy_paths: Vec<RadrootsdRuntimeLegacyPathOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RadrootsdRuntimeLegacyPathOutput { + pub id: String, + pub description: String, + pub path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination: Option<PathBuf>, + pub import_hint: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RadrootsdRuntimePathOverrideContractOutput { pub profile_source: String, pub root_source: String, @@ -213,6 +235,7 @@ fn runtime_contract_with_selection( .into_iter() .map(str::to_owned) .collect(), + migration: migration_contract_output(RadrootsMigrationReport::empty()), canonical_config_path: paths.config_path, canonical_logs_dir: paths.logs_dir, canonical_identity_path: paths.identity_path, @@ -220,6 +243,75 @@ fn runtime_contract_with_selection( }) } +pub(crate) fn runtime_migration_for_process( + contract: &RadrootsdRuntimeContractOutput, +) -> Result<RadrootsdRuntimeMigrationContractOutput> { + let current_dir = std::env::current_dir().context("resolve current directory")?; + Ok(runtime_migration_for_current_dir( + contract, + current_dir.as_path(), + )) +} + +pub(crate) fn runtime_migration_for_current_dir( + contract: &RadrootsdRuntimeContractOutput, + current_dir: &Path, +) -> RadrootsdRuntimeMigrationContractOutput { + let report = inspect_legacy_paths(legacy_path_candidates(contract, current_dir)); + migration_contract_output(report) +} + +fn legacy_path_candidates( + contract: &RadrootsdRuntimeContractOutput, + current_dir: &Path, +) -> Vec<RadrootsLegacyPathCandidate> { + vec![ + RadrootsLegacyPathCandidate::new( + "radrootsd_repo_config_v0", + "legacy radrootsd repo-relative config", + current_dir.join(DEFAULT_CONFIG_FILE_NAME), + Some(contract.canonical_config_path.clone()), + MIGRATION_IMPORT_HINT, + ), + RadrootsLegacyPathCandidate::new( + "radrootsd_repo_logs_v0", + "legacy radrootsd repo-relative logs directory", + current_dir.join("logs"), + Some(contract.canonical_logs_dir.clone()), + MIGRATION_IMPORT_HINT, + ), + RadrootsLegacyPathCandidate::new( + "radrootsd_repo_bridge_state_v0", + "legacy radrootsd repo-relative bridge state", + current_dir.join("state/bridge-jobs.json"), + Some(contract.canonical_bridge_state_path.clone()), + MIGRATION_IMPORT_HINT, + ), + ] +} + +fn migration_contract_output( + report: RadrootsMigrationReport, +) -> RadrootsdRuntimeMigrationContractOutput { + RadrootsdRuntimeMigrationContractOutput { + posture: report.posture.to_owned(), + state: report.state.to_owned(), + silent_startup_relocation: report.silent_startup_relocation, + compatibility_window: report.compatibility_window.to_owned(), + detected_legacy_paths: report + .detected_legacy_paths + .into_iter() + .map(|path| RadrootsdRuntimeLegacyPathOutput { + id: path.id, + description: path.description, + path: path.path, + destination: path.destination, + import_hint: path.import_hint, + }) + .collect(), + } +} + fn root_source_for_profile(profile: RadrootsPathProfile) -> &'static str { match profile { RadrootsPathProfile::InteractiveUser => "host_defaults", @@ -228,3 +320,62 @@ fn root_source_for_profile(profile: RadrootsPathProfile) -> &'static str { RadrootsPathProfile::MobileNative => "mobile_native_defaults", } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use radroots_runtime_paths::{ + RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, + }; + + use super::{runtime_contract_with_resolver, runtime_migration_for_current_dir}; + + fn linux_resolver() -> RadrootsPathResolver { + RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/treesap")), + ..RadrootsHostEnvironment::default() + }, + ) + } + + #[test] + fn runtime_migration_detects_legacy_repo_relative_state_without_moving_it() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + temp.path().join("config.toml"), + "[metadata]\nname = \"old\"\n", + ) + .expect("write old config"); + std::fs::create_dir_all(temp.path().join("state")).expect("state dir"); + std::fs::write(temp.path().join("state/bridge-jobs.json"), "[]") + .expect("write old bridge state"); + let contract = runtime_contract_with_resolver( + &linux_resolver(), + RadrootsPathProfile::InteractiveUser, + None, + ) + .expect("contract"); + + let report = runtime_migration_for_current_dir(&contract, temp.path()); + + assert_eq!(report.posture, "explicit_operator_import_required"); + assert_eq!(report.state, "legacy_state_detected"); + assert!(!report.silent_startup_relocation); + assert_eq!(report.detected_legacy_paths.len(), 2); + assert_eq!( + report.detected_legacy_paths[0].id, + "radrootsd_repo_config_v0" + ); + assert_eq!( + report.detected_legacy_paths[0].destination, + Some(contract.canonical_config_path) + ); + assert_eq!( + report.detected_legacy_paths[1].id, + "radrootsd_repo_bridge_state_v0" + ); + } +} diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -62,6 +62,7 @@ struct RadrootsdRuntimeStartupReport { bridge_state_path_source: String, canonical_bridge_state_path: PathBuf, path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput, + migration: paths::RadrootsdRuntimeMigrationContractOutput, default_shared_secret_backend: String, allowed_shared_secret_backends: Vec<String>, } @@ -156,6 +157,7 @@ fn runtime_startup_report( args: &cli::Args, settings: &config::Settings, contract: &paths::RadrootsdRuntimeContractOutput, + migration: paths::RadrootsdRuntimeMigrationContractOutput, ) -> RadrootsdRuntimeStartupReport { RadrootsdRuntimeStartupReport { active_profile: contract.active_profile.clone(), @@ -202,6 +204,7 @@ fn runtime_startup_report( ), canonical_bridge_state_path: contract.canonical_bridge_state_path.clone(), path_overrides: contract.path_overrides.clone(), + migration, default_shared_secret_backend: contract.default_shared_secret_backend.clone(), allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), } @@ -236,6 +239,10 @@ fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) { repo_local_root = ?report.path_overrides.repo_local_root, repo_local_root_source = ?report.path_overrides.repo_local_root_source, subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(), + migration_posture = report.migration.posture.as_str(), + migration_state = report.migration.state.as_str(), + migration_detected_legacy_paths = report.migration.detected_legacy_paths.len(), + silent_startup_relocation = report.migration.silent_startup_relocation, config_path = %report.config_path.display(), config_path_source = report.config_path_source.as_str(), canonical_config_path = %report.canonical_config_path.display(), @@ -403,7 +410,9 @@ pub async fn run() -> Result<()> { #[cfg(not(test))] { let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?; - let report = runtime_startup_report(&args, &settings, &contract); + let migration = + paths::runtime_migration_for_process(&contract).context("inspect runtime migration")?; + let report = runtime_startup_report(&args, &settings, &contract, migration); log_runtime_startup_report(&report); } @@ -573,6 +582,13 @@ mod tests { }, default_shared_secret_backend: "encrypted_file".to_string(), allowed_shared_secret_backends: vec!["encrypted_file".to_string()], + migration: paths::RadrootsdRuntimeMigrationContractOutput { + posture: "explicit_operator_import_required".to_string(), + state: "ready".to_string(), + silent_startup_relocation: false, + compatibility_window: "detect_and_report_only".to_string(), + detected_legacy_paths: Vec::new(), + }, canonical_config_path: PathBuf::from( "/home/treesap/.radroots/config/services/radrootsd/config.toml", ), @@ -822,7 +838,9 @@ mod tests { settings.config.service.logs_dir = "/tmp/radrootsd/logs".to_string(); settings.config.bridge.state_path = PathBuf::from("/tmp/radrootsd/bridge-jobs.json"); - let report = runtime_startup_report(&args, &settings, &sample_runtime_contract()); + let contract = sample_runtime_contract(); + let report = + runtime_startup_report(&args, &settings, &contract, contract.migration.clone()); assert_eq!( report, @@ -849,6 +867,7 @@ mod tests { "/home/treesap/.radroots/data/services/radrootsd/bridge/bridge-jobs.json" ), path_overrides: sample_runtime_contract().path_overrides, + migration: sample_runtime_contract().migration, default_shared_secret_backend: "encrypted_file".to_string(), allowed_shared_secret_backends: vec!["encrypted_file".to_string()], } @@ -869,7 +888,8 @@ mod tests { settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); settings.config.bridge.state_path = contract.canonical_bridge_state_path.clone(); - let report = runtime_startup_report(&args, &settings, &contract); + let report = + runtime_startup_report(&args, &settings, &contract, contract.migration.clone()); assert_eq!(report.config_path, contract.canonical_config_path); assert_eq!(report.config_path_source, "profile_default"); @@ -883,6 +903,7 @@ mod tests { ); assert_eq!(report.bridge_state_path_source, "profile_default"); assert_eq!(report.path_overrides, contract.path_overrides); + assert_eq!(report.migration, contract.migration); assert_eq!(report.default_shared_secret_backend, "encrypted_file"); assert_eq!( report.allowed_shared_secret_backends,