commit 87aeaf6162b4a3be5fc16913a8caa365e1a3a814
parent 5ddb930d7d2ded7758c765b4a3dc86720e5164c2
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Apr 2026 17:18:30 +0000
runtime: detect legacy startup paths
Diffstat:
3 files changed, 183 insertions(+), 5 deletions(-)
diff --git a/src/config.rs b/src/config.rs
@@ -397,6 +397,17 @@ replay_overlap_secs = 45
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/workers/rhi/config.toml")
);
diff --git a/src/main.rs b/src/main.rs
@@ -58,6 +58,7 @@ struct RhiRuntimeStartupReport {
subscriber_state_path_source: String,
canonical_subscriber_state_path: PathBuf,
path_overrides: paths::RhiRuntimePathOverrideContractOutput,
+ migration: paths::RhiRuntimeMigrationContractOutput,
default_shared_secret_backend: String,
allowed_shared_secret_backends: Vec<String>,
}
@@ -98,6 +99,7 @@ fn runtime_startup_report(
args: &cli_args,
settings: &config::Settings,
contract: &paths::RhiRuntimeContractOutput,
+ migration: paths::RhiRuntimeMigrationContractOutput,
) -> RhiRuntimeStartupReport {
RhiRuntimeStartupReport {
active_profile: contract.active_profile.clone(),
@@ -144,6 +146,7 @@ fn runtime_startup_report(
),
canonical_subscriber_state_path: contract.canonical_subscriber_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(),
}
@@ -178,6 +181,10 @@ fn log_runtime_startup_report(report: &RhiRuntimeStartupReport) {
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(),
@@ -202,7 +209,9 @@ 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);
}
@@ -263,6 +272,13 @@ mod tests {
},
default_shared_secret_backend: "encrypted_file".to_string(),
allowed_shared_secret_backends: vec!["encrypted_file".to_string()],
+ migration: paths::RhiRuntimeMigrationContractOutput {
+ 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/workers/rhi/config.toml",
),
@@ -371,7 +387,9 @@ mod tests {
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());
+ let contract = sample_runtime_contract();
+ let report =
+ runtime_startup_report(&args, &settings, &contract, contract.migration.clone());
assert_eq!(
report,
@@ -396,6 +414,7 @@ mod tests {
"/home/treesap/.radroots/data/workers/rhi/trade-listing/state.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()],
}
@@ -416,7 +435,8 @@ mod tests {
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);
+ 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");
@@ -430,6 +450,7 @@ mod tests {
);
assert_eq!(report.subscriber_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,
diff --git a/src/paths.rs b/src/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 RHI_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.subscriber.state.path"];
+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";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RhiRuntimePaths {
@@ -34,6 +36,7 @@ pub struct RhiRuntimeContractOutput {
pub path_overrides: RhiRuntimePathOverrideContractOutput,
pub default_shared_secret_backend: String,
pub allowed_shared_secret_backends: Vec<String>,
+ pub migration: RhiRuntimeMigrationContractOutput,
pub canonical_config_path: PathBuf,
pub canonical_logs_dir: PathBuf,
pub canonical_identity_path: PathBuf,
@@ -41,6 +44,25 @@ pub struct RhiRuntimeContractOutput {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct RhiRuntimeMigrationContractOutput {
+ pub posture: String,
+ pub state: String,
+ pub silent_startup_relocation: bool,
+ pub compatibility_window: String,
+ pub detected_legacy_paths: Vec<RhiRuntimeLegacyPathOutput>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct RhiRuntimeLegacyPathOutput {
+ 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 RhiRuntimePathOverrideContractOutput {
pub profile_source: String,
pub root_source: String,
@@ -209,6 +231,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,
@@ -216,6 +239,73 @@ fn runtime_contract_with_selection(
})
}
+pub fn runtime_migration_for_process(
+ contract: &RhiRuntimeContractOutput,
+) -> Result<RhiRuntimeMigrationContractOutput> {
+ 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: &RhiRuntimeContractOutput,
+ current_dir: &Path,
+) -> RhiRuntimeMigrationContractOutput {
+ let report = inspect_legacy_paths(legacy_path_candidates(contract, current_dir));
+ migration_contract_output(report)
+}
+
+fn legacy_path_candidates(
+ contract: &RhiRuntimeContractOutput,
+ current_dir: &Path,
+) -> Vec<RadrootsLegacyPathCandidate> {
+ vec![
+ RadrootsLegacyPathCandidate::new(
+ "rhi_repo_config_v0",
+ "legacy rhi repo-relative config",
+ current_dir.join(DEFAULT_CONFIG_FILE_NAME),
+ Some(contract.canonical_config_path.clone()),
+ MIGRATION_IMPORT_HINT,
+ ),
+ RadrootsLegacyPathCandidate::new(
+ "rhi_repo_logs_v0",
+ "legacy rhi repo-relative logs directory",
+ current_dir.join("logs"),
+ Some(contract.canonical_logs_dir.clone()),
+ MIGRATION_IMPORT_HINT,
+ ),
+ RadrootsLegacyPathCandidate::new(
+ "rhi_repo_subscriber_state_v0",
+ "legacy rhi repo-relative subscriber state",
+ current_dir.join("state/trade-listing-state.json"),
+ Some(contract.canonical_subscriber_state_path.clone()),
+ MIGRATION_IMPORT_HINT,
+ ),
+ ]
+}
+
+fn migration_contract_output(report: RadrootsMigrationReport) -> RhiRuntimeMigrationContractOutput {
+ RhiRuntimeMigrationContractOutput {
+ 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| RhiRuntimeLegacyPathOutput {
+ 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",
@@ -224,3 +314,59 @@ 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/trade-listing-state.json"), "{}")
+ .expect("write old subscriber 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, "rhi_repo_config_v0");
+ assert_eq!(
+ report.detected_legacy_paths[1].id,
+ "rhi_repo_subscriber_state_v0"
+ );
+ assert_eq!(
+ report.detected_legacy_paths[1].destination,
+ Some(contract.canonical_subscriber_state_path)
+ );
+ }
+}