commit ec32440a93480e8f8019ffbdf4b489d19b21a7bc
parent 28fdccb54c7dae642f532d10c2e92a00b3bf1075
Author: triesap <tyson@radroots.org>
Date: Sat, 11 Apr 2026 17:23:00 +0000
runtime_manager: cover registry and helper branches
Diffstat:
4 files changed, 842 insertions(+), 3 deletions(-)
diff --git a/crates/runtime_manager/src/lib.rs b/crates/runtime_manager/src/lib.rs
@@ -56,6 +56,16 @@ mod tests {
resolve_shared_paths, save_registry, upsert_instance,
};
+ fn assert_error_contains(err: &crate::RadrootsRuntimeManagerError, parts: &[&str]) {
+ let rendered = err.to_string();
+ for part in parts {
+ assert!(
+ rendered.contains(part),
+ "expected `{rendered}` to contain `{part}`"
+ );
+ }
+ }
+
const CONTRACT: &str = r#"
schema = "radroots-runtime-management"
schema_version = 1
@@ -160,6 +170,22 @@ preferred_cli_binding = true
}
#[test]
+ fn parse_contract_reports_invalid_toml() {
+ let err = parse_contract_str("schema = [").expect_err("invalid toml should fail");
+ assert_error_contains(&err, &["parse runtime management contract"]);
+ }
+
+ #[test]
+ fn parse_contract_rejects_unexpected_schema() {
+ let err = parse_contract_str(&CONTRACT.replace(
+ "schema = \"radroots-runtime-management\"",
+ "schema = \"wrong-schema\"",
+ ))
+ .expect_err("unexpected schema should fail");
+ assert_error_contains(&err, &["wrong-schema", crate::RUNTIME_MANAGEMENT_SCHEMA]);
+ }
+
+ #[test]
fn resolve_shared_paths_uses_interactive_user_roots() {
let contract = parse_contract_str(CONTRACT).expect("parse contract");
let resolver = RadrootsPathResolver::new(
@@ -188,9 +214,25 @@ preferred_cli_binding = true
PathBuf::from("/home/treesap/.radroots/data/shared/runtime-manager/installs")
);
assert_eq!(
+ paths.artifact_cache_dir,
+ PathBuf::from("/home/treesap/.radroots/cache/shared/runtime-manager/artifacts")
+ );
+ assert_eq!(
+ paths.state_root,
+ PathBuf::from("/home/treesap/.radroots/data/shared/runtime-manager/state")
+ );
+ assert_eq!(
paths.logs_root,
PathBuf::from("/home/treesap/.radroots/logs/shared/runtime-manager")
);
+ assert_eq!(
+ paths.run_root,
+ PathBuf::from("/home/treesap/.radroots/run/shared/runtime-manager")
+ );
+ assert_eq!(
+ paths.secrets_root,
+ PathBuf::from("/home/treesap/.radroots/secrets/shared/runtime-manager")
+ );
}
#[test]
diff --git a/crates/runtime_manager/src/lifecycle.rs b/crates/runtime_manager/src/lifecycle.rs
@@ -530,10 +530,14 @@ mod tests {
use tempfile::tempdir;
use super::{
- ensure_instance_layout, extract_binary_archive, install_binary, process_running,
- read_secret_file, remove_instance_artifacts, start_process, stop_process,
- write_instance_metadata, write_managed_file, write_secret_file,
+ ensure_instance_layout, ensure_parent_dir, extract_binary_archive, find_binary_with_name,
+ force_kill_process, install_binary, open_log_file, process_running,
+ process_running_for_pid, read_pid, read_secret_file, remove_instance_artifacts,
+ remove_path_if_exists, set_executable_mode, set_secret_mode, signal_process, start_process,
+ stop_process, terminate_process, write_instance_metadata, write_managed_file,
+ write_secret_file,
};
+ use crate::error::RadrootsRuntimeManagerError;
use crate::model::{ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord};
use crate::paths::ManagedRuntimeInstancePaths;
@@ -551,6 +555,16 @@ mod tests {
}
}
+ fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) {
+ let rendered = err.to_string();
+ for part in parts {
+ assert!(
+ rendered.contains(part),
+ "expected `{rendered}` to contain `{part}`"
+ );
+ }
+ }
+
#[test]
fn layout_and_metadata_helpers_write_expected_files() {
let dir = tempdir().expect("tempdir");
@@ -627,6 +641,30 @@ mod tests {
#[cfg(unix)]
#[test]
+ fn extract_binary_archive_uses_direct_binary_when_present_at_root() {
+ let dir = tempdir().expect("tempdir");
+ let archive_root = dir.path().join("archive");
+ fs::create_dir_all(&archive_root).expect("archive dir");
+ fs::write(archive_root.join("radrootsd"), "#!/bin/sh\nexit 0\n").expect("binary");
+ let archive_path = dir.path().join("radrootsd.tar.gz");
+ let file = File::create(&archive_path).expect("archive file");
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+ builder
+ .append_path_with_name(archive_root.join("radrootsd"), "radrootsd")
+ .expect("append path");
+ builder.finish().expect("finish archive");
+ let encoder = builder.into_inner().expect("into encoder");
+ encoder.finish().expect("finish gzip");
+
+ let paths = sample_paths(dir.path());
+ let installed =
+ extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd").expect("extract");
+ assert_eq!(installed, paths.install_dir.join("radrootsd"));
+ }
+
+ #[cfg(unix)]
+ #[test]
fn start_and_stop_process_manage_pid_file() {
let dir = tempdir().expect("tempdir");
let binary = dir.path().join("sleepy.sh");
@@ -654,4 +692,393 @@ mod tests {
assert!(!paths.run_dir.exists());
assert!(!paths.secrets_dir.exists());
}
+
+ #[test]
+ fn ensure_instance_layout_reports_directory_errors() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ fs::write(&paths.install_dir, "occupied").expect("file");
+
+ let err = ensure_instance_layout(&paths).expect_err("file path should fail");
+ assert_error_contains(
+ &err,
+ &[
+ paths.install_dir.to_string_lossy().as_ref(),
+ "create directory",
+ ],
+ );
+ }
+
+ #[test]
+ fn install_binary_reports_copy_errors() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ let err = install_binary(dir.path().join("missing"), &paths, "radrootsd")
+ .expect_err("missing source should fail");
+ assert_error_contains(
+ &err,
+ &[
+ dir.path().join("missing").to_string_lossy().as_ref(),
+ paths
+ .install_dir
+ .join("radrootsd")
+ .to_string_lossy()
+ .as_ref(),
+ "copy runtime binary",
+ ],
+ );
+ }
+
+ #[test]
+ fn extract_binary_archive_reports_unsupported_format() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ let archive_path = dir.path().join("radrootsd.zip");
+
+ let err = extract_binary_archive(&archive_path, "zip", &paths, "radrootsd")
+ .expect_err("unsupported archive format should fail");
+ assert_error_contains(&err, &[archive_path.to_string_lossy().as_ref(), "zip"]);
+ }
+
+ #[test]
+ fn extract_binary_archive_reports_missing_archive() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ let archive_path = dir.path().join("missing.tar.gz");
+
+ let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd")
+ .expect_err("missing archive should fail");
+ assert_error_contains(
+ &err,
+ &[archive_path.to_string_lossy().as_ref(), "read managed file"],
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn extract_binary_archive_reports_missing_binary_in_archive() {
+ let dir = tempdir().expect("tempdir");
+ let archive_root = dir.path().join("archive");
+ fs::create_dir_all(archive_root.join("bin")).expect("archive dir");
+ fs::write(archive_root.join("bin/other"), "#!/bin/sh\nexit 0\n").expect("binary");
+ let archive_path = dir.path().join("radrootsd.tar.gz");
+ let file = File::create(&archive_path).expect("archive file");
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+ builder
+ .append_path_with_name(archive_root.join("bin/other"), "radrootsd/bin/other")
+ .expect("append path");
+ builder.finish().expect("finish archive");
+ let encoder = builder.into_inner().expect("into encoder");
+ encoder.finish().expect("finish gzip");
+
+ let paths = sample_paths(dir.path());
+ let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd")
+ .expect_err("archive should not resolve missing binary");
+ assert_error_contains(
+ &err,
+ &[
+ paths
+ .install_dir
+ .join("radrootsd")
+ .to_string_lossy()
+ .as_ref(),
+ "did not produce",
+ ],
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn extract_binary_archive_reports_unpack_errors() {
+ let dir = tempdir().expect("tempdir");
+ let archive_path = dir.path().join("invalid.tar.gz");
+ fs::write(&archive_path, "not a gzip archive").expect("write archive");
+ let paths = sample_paths(dir.path());
+
+ let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd")
+ .expect_err("invalid archive should fail");
+ assert_error_contains(
+ &err,
+ &[archive_path.to_string_lossy().as_ref(), "unpack archive"],
+ );
+ }
+
+ #[test]
+ fn write_managed_file_reports_write_errors() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("as-dir");
+ fs::create_dir(&path).expect("create directory target");
+
+ let err = write_managed_file(&path, "value").expect_err("directory write should fail");
+ assert_error_contains(
+ &err,
+ &[path.to_string_lossy().as_ref(), "write managed file"],
+ );
+ }
+
+ #[test]
+ fn write_secret_file_reports_write_errors() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("as-dir");
+ fs::create_dir(&path).expect("create directory target");
+
+ let err = write_secret_file(&path, "secret").expect_err("directory write should fail");
+ assert_error_contains(
+ &err,
+ &[path.to_string_lossy().as_ref(), "write managed file"],
+ );
+ }
+
+ #[test]
+ fn read_secret_file_reports_missing_path() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("missing.secret");
+ let err = read_secret_file(&path).expect_err("missing secret should fail");
+ assert_error_contains(
+ &err,
+ &[path.to_string_lossy().as_ref(), "read managed file"],
+ );
+ }
+
+ #[test]
+ fn write_instance_metadata_reports_write_errors() {
+ let dir = tempdir().expect("tempdir");
+ let mut paths = sample_paths(dir.path());
+ ensure_instance_layout(&paths).expect("layout");
+ fs::create_dir_all(&paths.metadata_path).expect("create metadata dir");
+ paths.metadata_path = paths.metadata_path.clone();
+
+ let err = write_instance_metadata(
+ &paths,
+ &ManagedRuntimeInstanceRecord {
+ runtime_id: "radrootsd".to_owned(),
+ instance_id: "local".to_owned(),
+ management_mode: "interactive_user_managed".to_owned(),
+ install_state: ManagedRuntimeInstallState::Configured,
+ binary_path: paths.install_dir.join("radrootsd"),
+ config_path: paths.state_dir.join("config.toml"),
+ logs_path: paths.logs_dir.clone(),
+ run_path: paths.run_dir.clone(),
+ installed_version: "0.1.0".to_owned(),
+ health_endpoint: None,
+ secret_material_ref: None,
+ last_started_at: None,
+ last_stopped_at: None,
+ notes: None,
+ },
+ )
+ .expect_err("metadata dir target should fail");
+ assert_error_contains(
+ &err,
+ &[
+ paths.metadata_path.to_string_lossy().as_ref(),
+ "write runtime instance metadata",
+ ],
+ );
+ }
+
+ #[test]
+ fn start_process_reports_spawn_errors() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ let err = start_process(dir.path().join("missing"), &[], &[], &paths)
+ .expect_err("missing binary should fail");
+ assert_error_contains(
+ &err,
+ &[
+ dir.path().join("missing").to_string_lossy().as_ref(),
+ "spawn managed runtime process",
+ ],
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn start_process_reports_pid_file_write_errors() {
+ let dir = tempdir().expect("tempdir");
+ let binary = dir.path().join("sleepy.sh");
+ fs::write(&binary, "#!/bin/sh\nexec sleep 1\n").expect("script");
+ let mut paths = sample_paths(dir.path());
+ paths.pid_file_path = paths.run_dir.clone();
+ let installed =
+ install_binary(&binary, &sample_paths(dir.path()), "sleepy.sh").expect("install");
+
+ let err =
+ start_process(&installed, &[], &[], &paths).expect_err("pid file write should fail");
+ assert_error_contains(
+ &err,
+ &[paths.run_dir.to_string_lossy().as_ref(), "write pid file"],
+ );
+ }
+
+ #[test]
+ fn process_running_and_stop_process_handle_missing_pid_file() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+
+ assert!(!process_running(&paths).expect("missing pid should be false"));
+ assert!(!stop_process(&paths).expect("missing pid stop should be false"));
+ }
+
+ #[test]
+ fn process_running_reports_invalid_pid_file() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ ensure_instance_layout(&paths).expect("layout");
+ fs::write(&paths.pid_file_path, "not-a-pid").expect("write pid");
+
+ let err = process_running(&paths).expect_err("invalid pid should fail");
+ assert_error_contains(
+ &err,
+ &[paths.pid_file_path.to_string_lossy().as_ref(), "not-a-pid"],
+ );
+ }
+
+ #[test]
+ fn stop_process_clears_stale_pid_file() {
+ let dir = tempdir().expect("tempdir");
+ let paths = sample_paths(dir.path());
+ ensure_instance_layout(&paths).expect("layout");
+ fs::write(&paths.pid_file_path, "999999").expect("write pid");
+
+ assert!(!stop_process(&paths).expect("stale pid should return false"));
+ assert!(!paths.pid_file_path.exists());
+ }
+
+ #[test]
+ fn ensure_parent_dir_without_parent_is_a_noop() {
+ ensure_parent_dir(Path::new("/")).expect("root path should have no parent");
+ }
+
+ #[test]
+ fn ensure_parent_dir_reports_directory_creation_errors() {
+ let dir = tempdir().expect("tempdir");
+ let file_parent = dir.path().join("occupied");
+ fs::write(&file_parent, "file").expect("parent file");
+
+ let err =
+ ensure_parent_dir(&file_parent.join("child")).expect_err("file parent should fail");
+ assert_error_contains(
+ &err,
+ &[file_parent.to_string_lossy().as_ref(), "create directory"],
+ );
+ }
+
+ #[test]
+ fn find_binary_with_name_handles_nested_and_missing_files() {
+ let dir = tempdir().expect("tempdir");
+ fs::create_dir_all(dir.path().join("nested")).expect("nested dir");
+ fs::write(dir.path().join("nested/radrootsd"), "binary").expect("binary");
+
+ assert_eq!(
+ find_binary_with_name(dir.path(), "radrootsd"),
+ Some(dir.path().join("nested/radrootsd"))
+ );
+ assert_eq!(find_binary_with_name(dir.path(), "missing"), None);
+ }
+
+ #[test]
+ fn open_log_file_creates_file_and_reports_directory_errors() {
+ let dir = tempdir().expect("tempdir");
+ let file_path = dir.path().join("logs/stdout.log");
+ let file = open_log_file(&file_path).expect("open log");
+ drop(file);
+ assert!(file_path.is_file());
+
+ let bad_path = dir.path().join("bad");
+ fs::create_dir(&bad_path).expect("create dir");
+ let err = open_log_file(&bad_path).expect_err("directory open should fail");
+ assert_error_contains(
+ &err,
+ &[bad_path.to_string_lossy().as_ref(), "open runtime log file"],
+ );
+ }
+
+ #[test]
+ fn read_pid_handles_empty_missing_and_read_error_cases() {
+ let dir = tempdir().expect("tempdir");
+ let mut paths = sample_paths(dir.path());
+
+ assert_eq!(read_pid(&paths).expect("missing pid"), None);
+
+ ensure_instance_layout(&paths).expect("layout");
+ fs::write(&paths.pid_file_path, " ").expect("write pid");
+ assert_eq!(read_pid(&paths).expect("empty pid"), None);
+
+ paths.pid_file_path = paths.run_dir.clone();
+ let err = read_pid(&paths).expect_err("directory pid file should fail");
+ assert_error_contains(
+ &err,
+ &[paths.run_dir.to_string_lossy().as_ref(), "read pid file"],
+ );
+ }
+
+ #[test]
+ fn remove_path_if_exists_handles_files_directories_and_missing_paths() {
+ let dir = tempdir().expect("tempdir");
+ let file_path = dir.path().join("file.txt");
+ let dir_path = dir.path().join("subdir");
+ fs::write(&file_path, "data").expect("file");
+ fs::create_dir(&dir_path).expect("dir");
+
+ remove_path_if_exists(&file_path).expect("remove file");
+ remove_path_if_exists(&dir_path).expect("remove dir");
+ remove_path_if_exists(dir.path().join("missing").as_path()).expect("remove missing");
+
+ assert!(!file_path.exists());
+ assert!(!dir_path.exists());
+ }
+
+ #[test]
+ fn remove_pid_file_reports_directory_errors() {
+ let dir = tempdir().expect("tempdir");
+ let mut paths = sample_paths(dir.path());
+ ensure_instance_layout(&paths).expect("layout");
+ paths.pid_file_path = paths.run_dir.clone();
+
+ let err = super::remove_pid_file(&paths).expect_err("directory pid path should fail");
+ assert_error_contains(
+ &err,
+ &[
+ paths.run_dir.to_string_lossy().as_ref(),
+ "remove managed path",
+ ],
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn set_mode_helpers_report_missing_path_errors() {
+ let dir = tempdir().expect("tempdir");
+ let missing = dir.path().join("missing");
+
+ let err = set_executable_mode(&missing).expect_err("missing executable should fail");
+ assert_error_contains(
+ &err,
+ &[missing.to_string_lossy().as_ref(), "read managed file"],
+ );
+
+ let err = set_secret_mode(&missing).expect_err("missing secret should fail");
+ assert_error_contains(
+ &err,
+ &[missing.to_string_lossy().as_ref(), "read managed file"],
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn signal_helpers_cover_failure_paths() {
+ let missing_pid = 999_999_u32;
+ assert!(!process_running_for_pid(missing_pid));
+
+ let err = terminate_process(missing_pid).expect_err("terminate should fail");
+ assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]);
+
+ let err = force_kill_process(missing_pid).expect_err("force kill should fail");
+ assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]);
+
+ let err = signal_process(missing_pid, "-BOGUS").expect_err("invalid signal should fail");
+ assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]);
+ }
}
diff --git a/crates/runtime_manager/src/paths.rs b/crates/runtime_manager/src/paths.rs
@@ -155,3 +155,230 @@ fn root_class_path(
};
Ok(base.join(rel))
}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use radroots_runtime_paths::{
+ RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver,
+ RadrootsPaths, RadrootsPlatform,
+ };
+
+ use super::{bootstrap_runtime, resolve_shared_paths, root_class_path};
+ use crate::{
+ RadrootsRuntimeManagerError, model::RadrootsRuntimeManagementContract, parse_contract_str,
+ };
+
+ const CONTRACT: &str = r#"
+schema = "radroots-runtime-management"
+schema_version = 1
+owner_doc = "docs/migration/radroots-modular-runtime-management-bootstrap-rcl.md"
+runtime_registry = "registry.toml"
+distribution_contract = "distribution.toml"
+capabilities_contract = "capabilities.toml"
+
+[defaults]
+instance_cardinality = "single_default_instance"
+managed_runtime_lookup = "shared_instance_registry"
+explicit_runtime_endpoint_overrides_precede_managed_instance_binding = true
+global_path_mutation_forbidden = true
+
+[management_clients]
+active = ["cli"]
+defined = ["community-app-desktop"]
+
+[managed_runtime_targets]
+active = ["radrootsd"]
+defined = ["myc", "rhi"]
+bootstrap_only = ["hyf"]
+
+[lifecycle]
+actions = ["install", "uninstall", "start"]
+destructive_actions = ["uninstall"]
+health_states = ["not_installed", "running"]
+
+[mode.interactive_user_managed]
+contract_state = "active"
+platforms = ["linux", "macos", "windows"]
+supported_profiles = ["interactive_user", "repo_local"]
+service_manager_integration = false
+uses_absolute_binary_paths = true
+requires_explicit_pid_tracking = true
+requires_explicit_log_tracking = true
+default_instance_cardinality = "single_default_instance"
+
+[mode.service_host_managed]
+contract_state = "defined"
+platforms = ["linux", "macos", "windows"]
+supported_profiles = ["service_host"]
+service_manager_integration = true
+uses_absolute_binary_paths = true
+default_instance_cardinality = "single_default_instance"
+
+[paths.interactive_user_managed]
+shared_namespace = "shared/runtime-manager"
+instance_registry_root_class = "config"
+instance_registry_rel = "shared/runtime-manager/instances.toml"
+artifact_cache_root_class = "cache"
+artifact_cache_rel = "shared/runtime-manager/artifacts"
+install_root_class = "data"
+install_root_rel = "shared/runtime-manager/installs"
+state_root_class = "data"
+state_root_rel = "shared/runtime-manager/state"
+logs_root_class = "logs"
+logs_root_rel = "shared/runtime-manager"
+run_root_class = "run"
+run_root_rel = "shared/runtime-manager"
+secrets_root_class = "secrets"
+secrets_namespace_rel = "shared/runtime-manager"
+
+[instance_metadata]
+required_fields = ["runtime_id"]
+optional_fields = ["notes"]
+
+[bootstrap.radrootsd]
+runtime_id = "radrootsd"
+management_mode = "interactive_user_managed"
+default_instance_id = "local"
+install_strategy = "archive_unpack"
+config_format = "toml"
+requires_bootstrap_secret = true
+requires_config_bootstrap = true
+requires_signer_provider = false
+health_surface = "jsonrpc_status"
+preferred_cli_binding = true
+"#;
+
+ fn contract() -> RadrootsRuntimeManagementContract {
+ parse_contract_str(CONTRACT).expect("parse contract")
+ }
+
+ fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) {
+ let rendered = err.to_string();
+ for part in parts {
+ assert!(
+ rendered.contains(part),
+ "expected `{rendered}` to contain `{part}`"
+ );
+ }
+ }
+
+ fn linux_resolver() -> RadrootsPathResolver {
+ RadrootsPathResolver::new(
+ RadrootsPlatform::Linux,
+ RadrootsHostEnvironment {
+ home_dir: Some(PathBuf::from("/home/treesap")),
+ ..RadrootsHostEnvironment::default()
+ },
+ )
+ }
+
+ #[test]
+ fn bootstrap_lookup_reports_unknown_runtime() {
+ let err = bootstrap_runtime(&contract(), "missing-runtime").expect_err("missing runtime");
+ assert_error_contains(&err, &["missing-runtime", "no bootstrap entry"]);
+ }
+
+ #[test]
+ fn resolve_shared_paths_reports_unknown_management_mode() {
+ let err = resolve_shared_paths(
+ &contract(),
+ &linux_resolver(),
+ RadrootsPathProfile::InteractiveUser,
+ &RadrootsPathOverrides::default(),
+ "missing-mode",
+ )
+ .expect_err("missing mode should fail");
+ assert_error_contains(&err, &["management mode `missing-mode`"]);
+ }
+
+ #[test]
+ fn resolve_shared_paths_reports_unsupported_profile() {
+ let err = resolve_shared_paths(
+ &contract(),
+ &linux_resolver(),
+ RadrootsPathProfile::ServiceHost,
+ &RadrootsPathOverrides::default(),
+ "interactive_user_managed",
+ )
+ .expect_err("service_host should be unsupported for interactive mode");
+ assert_error_contains(&err, &["interactive_user_managed", "service_host"]);
+ }
+
+ #[test]
+ fn resolve_shared_paths_reports_missing_path_spec() {
+ let mut contract = contract();
+ contract.paths.remove("interactive_user_managed");
+
+ let err = resolve_shared_paths(
+ &contract,
+ &linux_resolver(),
+ RadrootsPathProfile::InteractiveUser,
+ &RadrootsPathOverrides::default(),
+ "interactive_user_managed",
+ )
+ .expect_err("missing path spec should fail");
+ assert_error_contains(
+ &err,
+ &["interactive_user_managed", "no shared path specification"],
+ );
+ }
+
+ #[test]
+ fn resolve_shared_paths_reports_unknown_root_class() {
+ let mut contract = contract();
+ contract
+ .paths
+ .get_mut("interactive_user_managed")
+ .expect("path spec")
+ .instance_registry_root_class = "bogus".to_string();
+
+ let err = resolve_shared_paths(
+ &contract,
+ &linux_resolver(),
+ RadrootsPathProfile::InteractiveUser,
+ &RadrootsPathOverrides::default(),
+ "interactive_user_managed",
+ )
+ .expect_err("unknown root class should fail");
+ assert_error_contains(&err, &["unknown root class `bogus`"]);
+ }
+
+ #[test]
+ fn root_class_path_maps_all_known_classes() {
+ let roots = RadrootsPaths {
+ config: PathBuf::from("/roots/config"),
+ data: PathBuf::from("/roots/data"),
+ cache: PathBuf::from("/roots/cache"),
+ logs: PathBuf::from("/roots/logs"),
+ run: PathBuf::from("/roots/run"),
+ secrets: PathBuf::from("/roots/secrets"),
+ };
+
+ assert_eq!(
+ root_class_path(&roots, "config", "a/b").expect("config root"),
+ PathBuf::from("/roots/config/a/b")
+ );
+ assert_eq!(
+ root_class_path(&roots, "data", "a/b").expect("data root"),
+ PathBuf::from("/roots/data/a/b")
+ );
+ assert_eq!(
+ root_class_path(&roots, "cache", "a/b").expect("cache root"),
+ PathBuf::from("/roots/cache/a/b")
+ );
+ assert_eq!(
+ root_class_path(&roots, "logs", "a/b").expect("logs root"),
+ PathBuf::from("/roots/logs/a/b")
+ );
+ assert_eq!(
+ root_class_path(&roots, "run", "a/b").expect("run root"),
+ PathBuf::from("/roots/run/a/b")
+ );
+ assert_eq!(
+ root_class_path(&roots, "secrets", "a/b").expect("secrets root"),
+ PathBuf::from("/roots/secrets/a/b")
+ );
+ }
+}
diff --git a/crates/runtime_manager/src/registry.rs b/crates/runtime_manager/src/registry.rs
@@ -91,3 +91,146 @@ pub fn remove_instance(
.position(|record| record.runtime_id == runtime_id && record.instance_id == instance_id)?;
Some(registry.instances.remove(index))
}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::PathBuf;
+
+ use tempfile::tempdir;
+
+ use super::{instance, load_registry, remove_instance, save_registry, upsert_instance};
+ use crate::{
+ ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord, ManagedRuntimeInstanceRegistry,
+ RadrootsRuntimeManagerError,
+ };
+
+ fn sample_record(runtime_id: &str, instance_id: &str) -> ManagedRuntimeInstanceRecord {
+ ManagedRuntimeInstanceRecord {
+ runtime_id: runtime_id.to_string(),
+ instance_id: instance_id.to_string(),
+ management_mode: "interactive_user_managed".to_string(),
+ install_state: ManagedRuntimeInstallState::Configured,
+ binary_path: PathBuf::from("/tmp/radrootsd"),
+ config_path: PathBuf::from("/tmp/config.toml"),
+ logs_path: PathBuf::from("/tmp/logs"),
+ run_path: PathBuf::from("/tmp/run"),
+ installed_version: "0.1.0-alpha.1".to_string(),
+ health_endpoint: Some("jsonrpc_status".to_string()),
+ secret_material_ref: None,
+ last_started_at: None,
+ last_stopped_at: None,
+ notes: Some("test".to_string()),
+ }
+ }
+
+ fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) {
+ let rendered = err.to_string();
+ for part in parts {
+ assert!(
+ rendered.contains(part),
+ "expected `{rendered}` to contain `{part}`"
+ );
+ }
+ }
+
+ #[test]
+ fn load_registry_returns_default_when_file_is_missing() {
+ let dir = tempdir().expect("tempdir");
+ let registry = load_registry(dir.path().join("missing.toml")).expect("missing registry");
+ assert_eq!(registry, ManagedRuntimeInstanceRegistry::default());
+ }
+
+ #[test]
+ fn load_registry_reports_read_errors() {
+ let dir = tempdir().expect("tempdir");
+ let err = load_registry(dir.path()).expect_err("directory should fail");
+ assert_error_contains(
+ &err,
+ &[
+ dir.path().to_string_lossy().as_ref(),
+ "read runtime instance registry",
+ ],
+ );
+ }
+
+ #[test]
+ fn load_registry_reports_parse_errors() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("instances.toml");
+ fs::write(&path, "not = [valid").expect("write invalid registry");
+
+ let err = load_registry(&path).expect_err("invalid registry should fail");
+ assert_error_contains(
+ &err,
+ &[
+ path.to_string_lossy().as_ref(),
+ "parse runtime instance registry",
+ ],
+ );
+ }
+
+ #[test]
+ fn save_registry_reports_write_errors() {
+ let dir = tempdir().expect("tempdir");
+ let path = dir.path().join("registry-dir");
+ fs::create_dir(&path).expect("create directory target");
+
+ let err = save_registry(&path, &ManagedRuntimeInstanceRegistry::default())
+ .expect_err("directory path should fail");
+ assert_error_contains(
+ &err,
+ &[
+ path.to_string_lossy().as_ref(),
+ "write runtime instance registry",
+ ],
+ );
+ }
+
+ #[test]
+ fn save_registry_reports_parent_creation_errors() {
+ let dir = tempdir().expect("tempdir");
+ let file_parent = dir.path().join("occupied");
+ fs::write(&file_parent, "file").expect("occupied parent");
+ let path = file_parent.join("instances.toml");
+
+ let err = save_registry(&path, &ManagedRuntimeInstanceRegistry::default())
+ .expect_err("file parent should fail");
+ assert_error_contains(
+ &err,
+ &[
+ file_parent.to_string_lossy().as_ref(),
+ "create runtime instance registry parent",
+ ],
+ );
+ }
+
+ #[test]
+ fn upsert_instance_replaces_existing_and_sorts_new_records() {
+ let mut registry = ManagedRuntimeInstanceRegistry::default();
+ upsert_instance(&mut registry, sample_record("radrootsd", "b"));
+ upsert_instance(&mut registry, sample_record("myc", "a"));
+
+ let mut replacement = sample_record("radrootsd", "b");
+ replacement.installed_version = "0.2.0".to_string();
+ upsert_instance(&mut registry, replacement);
+
+ assert_eq!(registry.instances.len(), 2);
+ assert_eq!(registry.instances[0].runtime_id, "myc");
+ assert_eq!(registry.instances[1].runtime_id, "radrootsd");
+ assert_eq!(registry.instances[1].installed_version, "0.2.0");
+ }
+
+ #[test]
+ fn instance_and_remove_instance_handle_missing_and_present_rows() {
+ let mut registry = ManagedRuntimeInstanceRegistry::default();
+ upsert_instance(&mut registry, sample_record("radrootsd", "local"));
+
+ assert!(instance(®istry, "myc", "local").is_none());
+ assert!(remove_instance(&mut registry, "myc", "local").is_none());
+
+ let removed = remove_instance(&mut registry, "radrootsd", "local").expect("remove");
+ assert_eq!(removed.runtime_id, "radrootsd");
+ assert!(registry.instances.is_empty());
+ }
+}