commit c9977d64d340f6802a4b20ebc979ba9410b2178b
parent 1265c34e656243fb3288d463f45c2e9ab7eb5563
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Apr 2026 17:18:30 +0000
runtime: detect legacy startup paths
Diffstat:
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,