lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 1db1a0d7047463c39ceb7b6cc4a551d3ed1abd7b
parent 301daed1f3e3327727e475723cfe58dda347a7ac
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 08:34:52 +0000

runtime-manager: adopt service instance paths

- bind manager targets and lifecycle paths to sealed runtime contexts
- seal typed registries and reject cross-profile or cross-root reuse
- remove credential authority and contain config, install, and cleanup operations
- verify focused, workspace, partitioned Studio, Clippy, and Rustdoc gates

Diffstat:
MAGENTS.md | 10++++++++++
Mcrates/runtime_manager/README | 19++++++++++++-------
Mcrates/runtime_manager/src/error.rs | 186+++++++++++++++++++++++++++----------------------------------------------------
Mcrates/runtime_manager/src/lib.rs | 365+++++++++++++++++++------------------------------------------------------------
Mcrates/runtime_manager/src/lifecycle.rs | 727++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/runtime_manager/src/managed.rs | 1369++++++++++++++++++++++++++++++++-----------------------------------------------
Mcrates/runtime_manager/src/model.rs | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/runtime_manager/src/paths.rs | 623++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/runtime_manager/src/registry.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
9 files changed, 1626 insertions(+), 2075 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -157,6 +157,16 @@ Before editing code: the owning store, keep live mutable state daemon-owned, and route live-state mutations from local tools through the typed, permissioned Unix-socket local-admin boundary. +- Runtime-management flows consume a sealed `RuntimeContext` for every service + instance. They must not reconstruct service paths from raw identifiers, + ambient selectors, or manager-owned roots, and registries must not persist + duplicate config, state, logs, run, secrets, or binary paths. Manager-owned + install and process-tracking artifacts remain separate; uninstall and + cleanup must never recursively delete canonical service state or secrets. + The manager has no credential read/write authority, executable artifact + names are validated single path components, and ordinary manager errors and + `Debug` output must not expose filesystem paths, file contents, or raw + dependency-owned causes. - Library code must not initialize a tracing subscriber, parse a process CLI, read service configuration from environment variables, install signal handlers, create a Tokio runtime, call `process::exit`, or spawn arbitrary diff --git a/crates/runtime_manager/README b/crates/runtime_manager/README @@ -6,13 +6,18 @@ runtime lifecycle and inspection helpers for the `radroots` core libraries. ## Overview * schema-checked management contract parsing and shared management constants; - * shared and per-instance path resolution layered on `radroots_runtime_paths`; - * registry load, save, lookup, and upsert helpers for managed runtime - instances; - * lifecycle helpers for archive install, process start and stop, managed-file - writes, and secret-file handling; - * managed runtime inspection and target-resolution helpers for status, logs, - config, and action availability. + * manager-owned shared and per-instance tracking paths kept separate from + canonical service-instance paths supplied by a sealed `RuntimeContext`; + * typed, deterministic registry load, save, lookup, and upsert helpers that + persist service/instance identities without duplicating service paths or + secrets; + * lifecycle helpers for contained archive install, process start and stop, + and context-bound non-secret configuration writes; the manager has no + credential read/write authority; + * context-bound runtime inspection and target-resolution helpers for status, + logs, config, and action availability; and + * cleanup behavior limited to manager-owned install and tracking artifacts, + preserving canonical service state and secrets. ## Copyright diff --git a/crates/runtime_manager/src/error.rs b/crates/runtime_manager/src/error.rs @@ -1,126 +1,68 @@ -use std::path::PathBuf; - -use radroots_runtime_paths::{RadrootsRuntimePathSelectionError, RadrootsRuntimePathsError}; use thiserror::Error; -#[derive(Debug, Error)] +/// Stable, path-free runtime-management failures. +/// +/// Filesystem paths, file contents, and dependency-owned causes are retained +/// only inside the operation that handles them. Ordinary `Display`, `Debug`, +/// and error-chain traversal therefore cannot disclose them. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum RadrootsRuntimeManagerError { - #[error("parse runtime management contract: {0}")] - Parse(String), - #[error("runtime management schema `{found}` does not match `{expected}`")] - UnexpectedSchema { - expected: &'static str, - found: String, - }, - #[error("management mode `{0}` not found in runtime management contract")] - UnknownManagementMode(String), - #[error("management mode `{mode_id}` does not support profile `{profile}`")] - UnsupportedProfile { mode_id: String, profile: String }, - #[error("management mode `{0}` has no shared path specification")] - MissingPathSpec(String), - #[error("unknown root class `{0}` in runtime management contract")] - UnknownRootClass(String), - #[error("runtime `{0}` has no bootstrap entry in runtime management contract")] - UnknownBootstrapRuntime(String), - #[error("read runtime instance registry {path}: {source}")] - ReadRegistry { - path: PathBuf, - source: std::io::Error, - }, - #[error("parse runtime instance registry {path}: {details}")] - ParseRegistry { path: PathBuf, details: String }, - #[error("serialize runtime instance registry: {0}")] - SerializeRegistry(String), - #[error("create runtime instance registry parent {path}: {source}")] - CreateRegistryParent { - path: PathBuf, - source: std::io::Error, - }, - #[error("write runtime instance registry {path}: {source}")] - WriteRegistry { - path: PathBuf, - source: std::io::Error, - }, - #[error("create directory {path}: {source}")] - CreateDirectory { - path: PathBuf, - source: std::io::Error, - }, - #[error("copy runtime binary from {from} to {to}: {source}")] - CopyBinary { - from: PathBuf, - to: PathBuf, - source: std::io::Error, - }, - #[error("serialize runtime instance metadata: {0}")] - SerializeInstanceMetadata(String), - #[error("write runtime instance metadata {path}: {source}")] - WriteInstanceMetadata { - path: PathBuf, - source: std::io::Error, - }, - #[error("write managed file {path}: {source}")] - WriteManagedFile { - path: PathBuf, - source: std::io::Error, - }, - #[error("read managed file {path}: {source}")] - ReadManagedFile { - path: PathBuf, - source: std::io::Error, - }, - #[error("open runtime log file {path}: {source}")] - OpenLogFile { - path: PathBuf, - source: std::io::Error, - }, - #[error("spawn managed runtime process {binary_path}: {source}")] - SpawnProcess { - binary_path: PathBuf, - source: std::io::Error, - }, - #[error("write pid file {path}: {source}")] - WritePidFile { - path: PathBuf, - source: std::io::Error, - }, - #[error("read pid file {path}: {source}")] - ReadPidFile { - path: PathBuf, - source: std::io::Error, - }, - #[error("parse pid file {path}: invalid contents `{contents}`")] - ParsePidFile { path: PathBuf, contents: String }, - #[error("remove managed path {path}: {source}")] - RemovePath { - path: PathBuf, - source: std::io::Error, - }, - #[error("set file permissions for {path}: {source}")] - SetPermissions { - path: PathBuf, - source: std::io::Error, - }, - #[error("signal pid {pid} with {signal}: {source}")] - ExecuteProcessSignal { - pid: u32, - signal: String, - source: std::io::Error, - }, - #[error("stop pid {pid}: {details}")] - StopProcess { pid: u32, details: String }, - #[error("unsupported archive format `{archive_format}` for {archive_path}")] - UnsupportedArchiveFormat { - archive_path: PathBuf, - archive_format: String, - }, - #[error("unpack archive {archive_path}: {source}")] - UnpackArchive { - archive_path: PathBuf, - source: std::io::Error, - }, - #[error(transparent)] - RuntimePaths(#[from] RadrootsRuntimePathsError), - #[error(transparent)] - RuntimePathSelection(#[from] RadrootsRuntimePathSelectionError), + #[error("parse runtime management contract failed")] + Parse, + #[error("runtime management schema is unsupported")] + UnexpectedSchema, + #[error("management mode does not support the selected profile")] + UnsupportedProfile, + #[error("runtime has no bootstrap entry in runtime management contract")] + UnknownBootstrapRuntime, + #[error("runtime context does not share the manager path scope")] + RuntimeContextMismatch, + #[error("read runtime instance registry failed: {kind}")] + ReadRegistry { kind: std::io::ErrorKind }, + #[error("parse runtime instance registry failed")] + ParseRegistry, + #[error("runtime instance registry schema is unsupported")] + UnexpectedRegistrySchema, + #[error("runtime instance registry version is unsupported")] + UnexpectedRegistryVersion, + #[error("runtime instance registry contains a duplicate service instance")] + DuplicateRegistryInstance, + #[error("serialize runtime instance registry failed")] + SerializeRegistry, + #[error("create runtime instance registry parent failed: {kind}")] + CreateRegistryParent { kind: std::io::ErrorKind }, + #[error("write runtime instance registry failed: {kind}")] + WriteRegistry { kind: std::io::ErrorKind }, + #[error("create managed runtime directory failed: {kind}")] + CreateDirectory { kind: std::io::ErrorKind }, + #[error("copy managed runtime binary failed: {kind}")] + CopyBinary { kind: std::io::ErrorKind }, + #[error("write managed runtime config failed: {kind}")] + WriteManagedConfig { kind: std::io::ErrorKind }, + #[error("read managed runtime file failed: {kind}")] + ReadManagedFile { kind: std::io::ErrorKind }, + #[error("open managed runtime log failed: {kind}")] + OpenLogFile { kind: std::io::ErrorKind }, + #[error("spawn managed runtime process failed: {kind}")] + SpawnProcess { kind: std::io::ErrorKind }, + #[error("write managed runtime pid failed: {kind}")] + WritePidFile { kind: std::io::ErrorKind }, + #[error("read managed runtime pid failed: {kind}")] + ReadPidFile { kind: std::io::ErrorKind }, + #[error("managed runtime pid is malformed")] + ParsePidFile, + #[error("remove manager-owned runtime path failed: {kind}")] + RemovePath { kind: std::io::ErrorKind }, + #[error("set managed runtime file permissions failed: {kind}")] + SetPermissions { kind: std::io::ErrorKind }, + #[error("signal managed runtime process failed: {kind}")] + ExecuteProcessSignal { kind: std::io::ErrorKind }, + #[error("managed runtime process did not stop")] + StopProcess, + #[error("managed runtime archive format is unsupported")] + UnsupportedArchiveFormat, + #[error("unpack managed runtime archive failed: {kind}")] + UnpackArchive { kind: std::io::ErrorKind }, + #[error("managed runtime artifact name is invalid")] + InvalidArtifactName, } diff --git a/crates/runtime_manager/src/lib.rs b/crates/runtime_manager/src/lib.rs @@ -9,9 +9,8 @@ pub mod registry; pub use error::RadrootsRuntimeManagerError; pub use lifecycle::{ - 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, + ManagedRuntimeArtifactName, ensure_instance_layout, extract_binary_archive, install_binary, + process_running, remove_instance_artifacts, start_process, stop_process, write_instance_config, }; pub use managed::{ ManagedRuntimeActionInspection, ManagedRuntimeConfigInspection, ManagedRuntimeContext, @@ -19,19 +18,17 @@ pub use managed::{ ManagedRuntimeLifecycleAction, ManagedRuntimeLogsInspection, ManagedRuntimeStatusInspection, ManagedRuntimeTarget, active_management_mode_for_profile, inspect_runtime_action, inspect_runtime_config, inspect_runtime_logs, inspect_runtime_status, load_management_context, - load_management_context_with_selection, resolve_runtime_target, runtime_group, + resolve_runtime_target, runtime_group, }; pub use model::{ BootstrapRuntimeContract, LifecycleContract, ManagedRuntimeHealthState, ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord, ManagedRuntimeInstanceRegistry, ManagementDefaults, ManagementModeContract, ManagementPathContract, + RUNTIME_INSTANCE_REGISTRY_SCHEMA, RUNTIME_INSTANCE_REGISTRY_VERSION, RadrootsRuntimeManagementContract, RuntimeGroups, }; -pub use paths::{ - ManagedRuntimeInstancePaths, ManagedRuntimeSharedPaths, bootstrap_runtime, - resolve_instance_paths, resolve_shared_paths, -}; -pub use registry::{instance, load_registry, remove_instance, save_registry, upsert_instance}; +pub use paths::{ManagedRuntimeInstancePaths, ManagedRuntimeSharedPaths, bootstrap_runtime}; +pub use registry::{instance, load_registry, save_registry}; pub const RUNTIME_MANAGEMENT_SCHEMA: &str = "radroots-runtime-management"; @@ -39,325 +36,139 @@ pub fn parse_contract_str( raw: &str, ) -> Result<RadrootsRuntimeManagementContract, RadrootsRuntimeManagerError> { let contract = toml::from_str::<RadrootsRuntimeManagementContract>(raw) - .map_err(|err| RadrootsRuntimeManagerError::Parse(err.to_string()))?; + .map_err(|_| RadrootsRuntimeManagerError::Parse)?; if contract.schema != RUNTIME_MANAGEMENT_SCHEMA { - return Err(RadrootsRuntimeManagerError::UnexpectedSchema { - expected: RUNTIME_MANAGEMENT_SCHEMA, - found: contract.schema.clone(), - }); + return Err(RadrootsRuntimeManagerError::UnexpectedSchema); } Ok(contract) } #[cfg(test)] mod tests { - use std::path::PathBuf; - - use radroots_runtime_paths::{ - RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, - RadrootsPlatform, - }; - use tempfile::tempdir; + use std::error::Error as _; - use crate::{ - ManagedRuntimeHealthState, ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord, - bootstrap_runtime, instance, load_registry, parse_contract_str, resolve_instance_paths, - 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}`" - ); - } - } + use super::{RUNTIME_MANAGEMENT_SCHEMA, parse_contract_str}; const CONTRACT: &str = r#" schema = "radroots-runtime-management" schema_version = 1 -owner_doc = "docs/execution/rcl/radroots-modular-runtime-management-bootstrap-rcl.md" -runtime_registry = "registry.toml" -distribution_contract = "distribution.toml" -capabilities_contract = "capabilities.toml" +owner_doc = "owner" +runtime_registry = "registry" +distribution_contract = "distribution" +capabilities_contract = "capabilities" [defaults] -instance_cardinality = "single_default_instance" -managed_runtime_lookup = "shared_instance_registry" +instance_cardinality = "multiple" +managed_runtime_lookup = "typed_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", "stop", "restart", "status", "logs", "config_show", "config_set"] -destructive_actions = ["uninstall"] -health_states = ["not_installed", "stopped", "starting", "running", "degraded", "failed"] +actions = ["status"] +health_states = ["running"] -[mode.interactive_user_managed] +[mode.interactive] contract_state = "active" -platforms = ["linux", "macos", "windows"] -supported_profiles = ["interactive_user", "repo_local"] +platforms = ["linux"] +supported_profiles = ["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" +default_instance_cardinality = "multiple" -[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" +[paths.interactive] +shared_namespace = "obsolete" instance_registry_root_class = "config" -instance_registry_rel = "shared/runtime-manager/instances.toml" +instance_registry_rel = "obsolete" artifact_cache_root_class = "cache" -artifact_cache_rel = "shared/runtime-manager/artifacts" +artifact_cache_rel = "obsolete" install_root_class = "data" -install_root_rel = "shared/runtime-manager/installs" +install_root_rel = "obsolete" state_root_class = "data" -state_root_rel = "shared/runtime-manager/state" +state_root_rel = "obsolete" logs_root_class = "logs" -logs_root_rel = "shared/runtime-manager" +logs_root_rel = "obsolete" run_root_class = "run" -run_root_rel = "shared/runtime-manager" +run_root_rel = "obsolete" secrets_root_class = "secrets" -secrets_namespace_rel = "shared/runtime-manager" +secrets_namespace_rel = "obsolete" [instance_metadata] -required_fields = [ - "runtime_id", - "instance_id", - "management_mode", - "install_state", - "binary_path", - "config_path", - "logs_path", - "run_path", - "installed_version", -] -optional_fields = [ - "health_endpoint", - "secret_material_ref", - "last_started_at", - "last_stopped_at", - "notes", -] +required_fields = ["service_id", "instance_id"] -[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 +[bootstrap] "#; #[test] - fn parse_contract_accepts_expected_schema() { - let contract = parse_contract_str(CONTRACT).expect("parse contract"); - assert_eq!(contract.schema, crate::RUNTIME_MANAGEMENT_SCHEMA); - assert!(contract.mode.contains_key("interactive_user_managed")); - } - - #[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"]); - } + fn contract_parser_accepts_only_the_expected_schema() { + let contract = parse_contract_str(CONTRACT).expect("contract"); + assert_eq!(contract.schema, RUNTIME_MANAGEMENT_SCHEMA); - #[test] - fn parse_contract_rejects_unexpected_schema() { - let err = parse_contract_str(&CONTRACT.replace( + let wrong = 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( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/treesap")), - xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), - ..RadrootsHostEnvironment::default() - }, - ); - - let paths = resolve_shared_paths( - &contract, - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - "interactive_user_managed", - ) - .expect("resolve shared manager paths"); - - assert_eq!( - paths.instance_registry_path, - PathBuf::from("/home/treesap/.config/radroots/shared/runtime-manager/instances.toml") - ); - assert_eq!( - paths.install_root, - PathBuf::from("/home/treesap/.local/share/radroots/shared/runtime-manager/installs") - ); - assert_eq!( - paths.artifact_cache_dir, - PathBuf::from("/home/treesap/.cache/radroots/shared/runtime-manager/artifacts") - ); - assert_eq!( - paths.state_root, - PathBuf::from("/home/treesap/.local/share/radroots/shared/runtime-manager/state") - ); - assert_eq!( - paths.logs_root, - PathBuf::from("/home/treesap/.local/state/radroots/logs/shared/runtime-manager") - ); - assert_eq!( - paths.run_root, - PathBuf::from("/run/user/1000/radroots/shared/runtime-manager") - ); - assert_eq!( - paths.secrets_root, - PathBuf::from("/home/treesap/.config/radroots/secrets/shared/runtime-manager") + "schema = \"wrong\"", ); + assert!(parse_contract_str(&wrong).is_err()); + assert!(parse_contract_str("schema = [").is_err()); } #[test] - fn resolve_repo_local_paths_uses_explicit_base_root() { - let contract = parse_contract_str(CONTRACT).expect("parse contract"); - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - - let paths = resolve_shared_paths( - &contract, - &resolver, - RadrootsPathProfile::RepoLocal, - &RadrootsPathOverrides::repo_local("/repo/.local/radroots"), - "interactive_user_managed", - ) - .expect("resolve repo local manager paths"); - - assert_eq!( - paths.state_root, - PathBuf::from("/repo/.local/radroots/data/shared/runtime-manager/state") - ); - } - - #[test] - fn resolve_instance_paths_builds_per_runtime_layout() { - let contract = parse_contract_str(CONTRACT).expect("parse contract"); - let resolver = RadrootsPathResolver::new( - RadrootsPlatform::Macos, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/Users/treesap")), - ..RadrootsHostEnvironment::default() - }, - ); - let shared = resolve_shared_paths( - &contract, - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - "interactive_user_managed", - ) - .expect("resolve shared manager paths"); - - let instance_paths = resolve_instance_paths(&shared, "radrootsd", "local"); - assert_eq!( - instance_paths.install_dir, - PathBuf::from( - "/Users/treesap/Library/Application Support/Radroots/data/shared/runtime-manager/installs/radrootsd/local" - ) - ); - assert_eq!( - instance_paths.pid_file_path, - PathBuf::from( - "/Users/treesap/Library/Application Support/Radroots/run/shared/runtime-manager/radrootsd/local/runtime.pid" - ) - ); - assert_eq!( - instance_paths.metadata_path, - PathBuf::from( - "/Users/treesap/Library/Application Support/Radroots/data/shared/runtime-manager/state/radrootsd/local/instance.toml" - ) - ); - } - - #[test] - fn registry_round_trip_persists_and_reloads_instances() { - let dir = tempdir().expect("tempdir"); - let registry_path = dir.path().join("instances.toml"); - let mut registry = crate::ManagedRuntimeInstanceRegistry::default(); - upsert_instance( - &mut registry, - ManagedRuntimeInstanceRecord { - runtime_id: "radrootsd".to_string(), - instance_id: "local".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: "1.0.0-alpha.1".to_string(), - health_endpoint: Some("jsonrpc_status".to_string()), - secret_material_ref: Some( - "shared/runtime-manager/radrootsd/local/token".to_string(), + fn contract_errors_redact_raw_schema_values_and_parser_causes() { + for (raw, secret) in [ + ( + CONTRACT.replace( + "schema = \"radroots-runtime-management\"", + "schema = \"/sensitive/root/secret-schema\"", ), - last_started_at: Some("2026-04-08T00:00:00Z".to_string()), - last_stopped_at: None, - notes: Some("test".to_string()), - }, - ); - - save_registry(&registry_path, &registry).expect("save registry"); - let reloaded = load_registry(&registry_path).expect("load registry"); - let record = instance(&reloaded, "radrootsd", "local").expect("instance record"); - assert_eq!(record.install_state, ManagedRuntimeInstallState::Configured); - assert_eq!(record.health_endpoint.as_deref(), Some("jsonrpc_status")); - } - - #[test] - fn bootstrap_lookup_returns_radrootsd_contract() { - let contract = parse_contract_str(CONTRACT).expect("parse contract"); - let bootstrap = bootstrap_runtime(&contract, "radrootsd").expect("bootstrap contract"); - assert_eq!(bootstrap.default_instance_id, "local"); - assert_eq!(bootstrap.health_surface, "jsonrpc_status"); - assert!(bootstrap.preferred_cli_binding); + "/sensitive/root/secret-schema", + ), + ( + "credential = 'secret-value'\ninvalid = [".to_owned(), + "secret-value", + ), + ] { + let err = parse_contract_str(&raw).expect_err("invalid contract"); + let rendered = format!("{err} {err:?}"); + assert!(!rendered.contains(secret)); + assert!(err.source().is_none()); + } } #[test] - fn install_and_health_state_surface_is_typed() { - assert_eq!( - ManagedRuntimeInstallState::Installed, - ManagedRuntimeInstallState::Installed - ); - assert_eq!( - ManagedRuntimeHealthState::Degraded, - ManagedRuntimeHealthState::Degraded - ); + fn manager_source_no_longer_consumes_legacy_path_selection_or_raw_identity_joins() { + let sources = [ + include_str!("error.rs"), + include_str!("lifecycle.rs"), + include_str!("managed.rs"), + include_str!("model.rs"), + include_str!("paths.rs"), + include_str!("registry.rs"), + ]; + for forbidden in [ + "RadrootsRuntimePathSelection", + "load_management_context_with_selection", + "PathBuf::from(runtime_id).join(instance_id)", + "record.config_path", + "record.logs_path", + "record.run_path", + "workers/rhi", + "pub fn read_secret_file", + "pub fn write_secret_file", + "pub fn write_managed_file", + "pub fn registry_mut", + "pub fn upsert_instance", + "pub fn resolve_shared_paths", + "pub fn resolve_instance_paths", + ] { + assert!( + sources.iter().all(|source| !source.contains(forbidden)), + "runtime manager retained forbidden source `{forbidden}`" + ); + } } } diff --git a/crates/runtime_manager/src/lifecycle.rs b/crates/runtime_manager/src/lifecycle.rs @@ -7,23 +7,46 @@ use std::time::Duration; use flate2::read::GzDecoder; use crate::error::RadrootsRuntimeManagerError; -use crate::model::ManagedRuntimeInstanceRecord; use crate::paths::ManagedRuntimeInstancePaths; +/// A validated single-component manager-owned executable artifact name. +#[derive(Clone, PartialEq, Eq)] +pub struct ManagedRuntimeArtifactName(String); + +impl ManagedRuntimeArtifactName { + pub fn new(value: &str) -> Result<Self, RadrootsRuntimeManagerError> { + if value.is_empty() + || value.len() > 128 + || !value.as_bytes()[0].is_ascii_alphanumeric() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || Path::new(value).components().count() != 1 + { + return Err(RadrootsRuntimeManagerError::InvalidArtifactName); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl core::fmt::Debug for ManagedRuntimeArtifactName { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str("ManagedRuntimeArtifactName([redacted])") + } +} + pub fn ensure_instance_layout( paths: &ManagedRuntimeInstancePaths, ) -> Result<(), RadrootsRuntimeManagerError> { - for path in [ - &paths.install_dir, - &paths.state_dir, - &paths.logs_dir, - &paths.run_dir, - &paths.secrets_dir, - ] { + for path in [paths.install_dir(), paths.logs_dir(), paths.run_dir()] { fs::create_dir_all(path).map_err(|source| { RadrootsRuntimeManagerError::CreateDirectory { - path: path.clone(), - source, + kind: source.kind(), } })?; } @@ -33,7 +56,7 @@ pub fn ensure_instance_layout( pub fn install_binary( source_binary_path: impl AsRef<Path>, paths: &ManagedRuntimeInstancePaths, - binary_name: &str, + binary_name: &ManagedRuntimeArtifactName, ) -> Result<PathBuf, RadrootsRuntimeManagerError> { install_binary_path(source_binary_path.as_ref(), paths, binary_name) } @@ -41,15 +64,13 @@ pub fn install_binary( fn install_binary_path( source_binary_path: &Path, paths: &ManagedRuntimeInstancePaths, - binary_name: &str, + binary_name: &ManagedRuntimeArtifactName, ) -> Result<PathBuf, RadrootsRuntimeManagerError> { ensure_instance_layout(paths)?; - let installed_binary_path = paths.install_dir.join(binary_name); + let installed_binary_path = paths.install_dir().join(binary_name.as_str()); fs::copy(source_binary_path, &installed_binary_path).map_err(|source| { RadrootsRuntimeManagerError::CopyBinary { - from: source_binary_path.to_path_buf(), - to: installed_binary_path.clone(), - source, + kind: source.kind(), } })?; set_executable_mode(&installed_binary_path)?; @@ -60,7 +81,7 @@ pub fn extract_binary_archive( archive_path: impl AsRef<Path>, archive_format: &str, paths: &ManagedRuntimeInstancePaths, - binary_name: &str, + binary_name: &ManagedRuntimeArtifactName, ) -> Result<PathBuf, RadrootsRuntimeManagerError> { extract_binary_archive_path(archive_path.as_ref(), archive_format, paths, binary_name) } @@ -69,107 +90,53 @@ fn extract_binary_archive_path( archive_path: &Path, archive_format: &str, paths: &ManagedRuntimeInstancePaths, - binary_name: &str, + binary_name: &ManagedRuntimeArtifactName, ) -> Result<PathBuf, RadrootsRuntimeManagerError> { - remove_path_if_exists(&paths.install_dir)?; + remove_path_if_exists(paths.install_dir())?; ensure_instance_layout(paths)?; match archive_format { - "tar.gz" => unpack_tar_gz_archive(archive_path, &paths.install_dir)?, - other => { - return Err(RadrootsRuntimeManagerError::UnsupportedArchiveFormat { - archive_path: archive_path.to_path_buf(), - archive_format: other.to_owned(), - }); - } + "tar.gz" => unpack_tar_gz_archive(archive_path, paths.install_dir())?, + _ => return Err(RadrootsRuntimeManagerError::UnsupportedArchiveFormat), } - let installed_binary_path = paths.install_dir.join(binary_name); + let installed_binary_path = paths.install_dir().join(binary_name.as_str()); let resolved_binary_path = if installed_binary_path.is_file() { installed_binary_path } else { - find_binary_with_name(&paths.install_dir, binary_name).ok_or_else(|| { + find_binary_with_name(paths.install_dir(), binary_name.as_str()).ok_or( RadrootsRuntimeManagerError::ReadManagedFile { - path: paths.install_dir.join(binary_name), - source: std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "archive {} did not produce a `{binary_name}` binary under {}", - archive_path.display(), - paths.install_dir.display() - ), - ), - } - })? + kind: std::io::ErrorKind::NotFound, + }, + )? }; set_executable_mode(&resolved_binary_path)?; Ok(resolved_binary_path) } -pub fn write_instance_metadata( +pub fn write_instance_config( paths: &ManagedRuntimeInstancePaths, - record: &ManagedRuntimeInstanceRecord, -) -> Result<(), RadrootsRuntimeManagerError> { - ensure_instance_layout(paths)?; - let raw = serialize_instance_metadata(record)?; - fs::write(&paths.metadata_path, raw).map_err(|source| { - RadrootsRuntimeManagerError::WriteInstanceMetadata { - path: paths.metadata_path.clone(), - source, - } - }) -} - -pub fn write_managed_file( - path: impl AsRef<Path>, - contents: &str, -) -> Result<(), RadrootsRuntimeManagerError> { - write_managed_file_path(path.as_ref(), contents) -} - -fn write_managed_file_path(path: &Path, contents: &str) -> Result<(), RadrootsRuntimeManagerError> { - ensure_parent_dir(path)?; - fs::write(path, contents).map_err(|source| RadrootsRuntimeManagerError::WriteManagedFile { - path: path.to_path_buf(), - source, - }) -} - -pub fn write_secret_file( - path: impl AsRef<Path>, contents: &str, ) -> Result<(), RadrootsRuntimeManagerError> { - write_secret_file_path(path.as_ref(), contents) -} - -fn write_secret_file_path(path: &Path, contents: &str) -> Result<(), RadrootsRuntimeManagerError> { - ensure_parent_dir(path)?; - fs::write(path, contents).map_err(|source| RadrootsRuntimeManagerError::WriteManagedFile { - path: path.to_path_buf(), - source, - })?; - set_secret_mode(path)?; - Ok(()) -} - -pub fn read_secret_file(path: impl AsRef<Path>) -> Result<String, RadrootsRuntimeManagerError> { - read_secret_file_path(path.as_ref()) -} - -fn read_secret_file_path(path: &Path) -> Result<String, RadrootsRuntimeManagerError> { - fs::read_to_string(path).map_err(|source| RadrootsRuntimeManagerError::ReadManagedFile { - path: path.to_path_buf(), - source, + let path = paths.config_path(); + ensure_parent_dir(&path)?; + fs::write(path, contents).map_err(|source| RadrootsRuntimeManagerError::WriteManagedConfig { + kind: source.kind(), }) } pub fn start_process( - binary_path: impl AsRef<Path>, + paths: &ManagedRuntimeInstancePaths, + binary_name: &ManagedRuntimeArtifactName, args: &[String], envs: &[(String, String)], - paths: &ManagedRuntimeInstancePaths, ) -> Result<u32, RadrootsRuntimeManagerError> { - start_process_path(binary_path.as_ref(), args, envs, paths) + start_process_path( + &paths.install_dir().join(binary_name.as_str()), + args, + envs, + paths, + ) } fn start_process_path( @@ -179,8 +146,8 @@ fn start_process_path( paths: &ManagedRuntimeInstancePaths, ) -> Result<u32, RadrootsRuntimeManagerError> { ensure_instance_layout(paths)?; - let stdout = open_log_file(&paths.stdout_log_path)?; - let stderr = open_log_file(&paths.stderr_log_path)?; + let stdout = open_log_file(paths.stdout_log_path())?; + let stderr = open_log_file(paths.stderr_log_path())?; let child = Command::new(binary_path) .args(args) .envs(envs.iter().map(|(key, value)| (key, value))) @@ -189,14 +156,12 @@ fn start_process_path( .stderr(Stdio::from(stderr)) .spawn() .map_err(|source| RadrootsRuntimeManagerError::SpawnProcess { - binary_path: binary_path.to_path_buf(), - source, + kind: source.kind(), })?; let pid = child.id(); - fs::write(&paths.pid_file_path, pid.to_string()).map_err(|source| { + fs::write(paths.pid_file_path(), pid.to_string()).map_err(|source| { RadrootsRuntimeManagerError::WritePidFile { - path: paths.pid_file_path.clone(), - source, + kind: source.kind(), } })?; Ok(pid) @@ -239,33 +204,12 @@ pub fn stop_process( pub fn remove_instance_artifacts( paths: &ManagedRuntimeInstancePaths, ) -> Result<(), RadrootsRuntimeManagerError> { - for path in [ - &paths.install_dir, - &paths.state_dir, - &paths.logs_dir, - &paths.run_dir, - &paths.secrets_dir, - ] { + for path in [paths.install_dir(), paths.logs_dir(), paths.run_dir()] { remove_path_if_exists(path)?; } Ok(()) } -fn serialize_instance_metadata( - record: &ManagedRuntimeInstanceRecord, -) -> Result<String, RadrootsRuntimeManagerError> { - serialize_instance_metadata_with(record, toml::to_string_pretty) -} - -fn serialize_instance_metadata_with( - record: &ManagedRuntimeInstanceRecord, - serializer: fn(&ManagedRuntimeInstanceRecord) -> Result<String, toml::ser::Error>, -) -> Result<String, RadrootsRuntimeManagerError> { - serializer(record).map_err(|details| { - RadrootsRuntimeManagerError::SerializeInstanceMetadata(details.to_string()) - }) -} - fn stop_process_for_pid( paths: &ManagedRuntimeInstancePaths, pid: u32, @@ -292,10 +236,7 @@ fn stop_process_for_pid( sleep(Duration::from_millis(100)); } - Err(RadrootsRuntimeManagerError::StopProcess { - pid, - details: "process did not exit after terminate and force-kill attempts".to_owned(), - }) + Err(RadrootsRuntimeManagerError::StopProcess) } fn unpack_tar_gz_archive( @@ -304,8 +245,7 @@ fn unpack_tar_gz_archive( ) -> Result<(), RadrootsRuntimeManagerError> { let archive_file = File::open(archive_path).map_err(|source| { RadrootsRuntimeManagerError::ReadManagedFile { - path: archive_path.to_path_buf(), - source, + kind: source.kind(), } })?; let decoder = GzDecoder::new(archive_file); @@ -313,8 +253,7 @@ fn unpack_tar_gz_archive( archive .unpack(destination_dir) .map_err(|source| RadrootsRuntimeManagerError::UnpackArchive { - archive_path: archive_path.to_path_buf(), - source, + kind: source.kind(), }) } @@ -342,8 +281,7 @@ fn open_log_file(path: &Path) -> Result<File, RadrootsRuntimeManagerError> { .append(true) .open(path) .map_err(|source| RadrootsRuntimeManagerError::OpenLogFile { - path: path.to_path_buf(), - source, + kind: source.kind(), }) } @@ -352,21 +290,19 @@ fn ensure_parent_dir(path: &Path) -> Result<(), RadrootsRuntimeManagerError> { return Ok(()); }; fs::create_dir_all(parent).map_err(|source| RadrootsRuntimeManagerError::CreateDirectory { - path: parent.to_path_buf(), - source, + kind: source.kind(), }) } fn read_pid( paths: &ManagedRuntimeInstancePaths, ) -> Result<Option<u32>, RadrootsRuntimeManagerError> { - let raw = match fs::read_to_string(&paths.pid_file_path) { + let raw = match fs::read_to_string(paths.pid_file_path()) { Ok(raw) => raw, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(source) => { return Err(RadrootsRuntimeManagerError::ReadPidFile { - path: paths.pid_file_path.clone(), - source, + kind: source.kind(), }); } }; @@ -377,19 +313,15 @@ fn read_pid( trimmed .parse::<u32>() .map(Some) - .map_err(|_| RadrootsRuntimeManagerError::ParsePidFile { - path: paths.pid_file_path.clone(), - contents: trimmed.to_owned(), - }) + .map_err(|_| RadrootsRuntimeManagerError::ParsePidFile) } fn remove_pid_file(paths: &ManagedRuntimeInstancePaths) -> Result<(), RadrootsRuntimeManagerError> { - match fs::remove_file(&paths.pid_file_path) { + match fs::remove_file(paths.pid_file_path()) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(source) => Err(RadrootsRuntimeManagerError::RemovePath { - path: paths.pid_file_path.clone(), - source, + kind: source.kind(), }), } } @@ -419,20 +351,17 @@ fn remove_path_from_state( match state { Ok(Some(ExistingPathKind::Directory)) => { remove_dir_all(path).map_err(|source| RadrootsRuntimeManagerError::RemovePath { - path: path.to_path_buf(), - source, + kind: source.kind(), }) } Ok(Some(ExistingPathKind::File)) => { remove_file(path).map_err(|source| RadrootsRuntimeManagerError::RemovePath { - path: path.to_path_buf(), - source, + kind: source.kind(), }) } Ok(None) => Ok(()), Err(source) => Err(RadrootsRuntimeManagerError::ReadManagedFile { - path: path.to_path_buf(), - source, + kind: source.kind(), }), } } @@ -456,11 +385,6 @@ fn set_executable_mode(_path: &Path) -> Result<(), RadrootsRuntimeManagerError> } #[cfg(unix)] -fn set_secret_mode(path: &Path) -> Result<(), RadrootsRuntimeManagerError> { - apply_mode(path, 0o600, set_permissions_path) -} - -#[cfg(unix)] fn apply_mode( path: &Path, mode: u32, @@ -470,15 +394,13 @@ fn apply_mode( let metadata = fs::metadata(path).map_err(|source| RadrootsRuntimeManagerError::ReadManagedFile { - path: path.to_path_buf(), - source, + kind: source.kind(), })?; let mut permissions = metadata.permissions(); permissions.set_mode(mode); set_permissions(path, permissions).map_err(|source| { RadrootsRuntimeManagerError::SetPermissions { - path: path.to_path_buf(), - source, + kind: source.kind(), } }) } @@ -488,11 +410,6 @@ fn set_permissions_path(path: &Path, permissions: fs::Permissions) -> std::io::R fs::set_permissions(path, permissions) } -#[cfg(not(unix))] -fn set_secret_mode(_path: &Path) -> Result<(), RadrootsRuntimeManagerError> { - Ok(()) -} - #[cfg(unix)] fn process_running_for_pid(pid: u32) -> bool { let pid_arg = pid.to_string(); @@ -582,18 +499,13 @@ fn signal_process_with( ) -> Result<(), RadrootsRuntimeManagerError> { let status = runner(pid, signal).map_err(|source| { RadrootsRuntimeManagerError::ExecuteProcessSignal { - pid, - signal: signal.to_owned(), - source, + kind: source.kind(), } })?; if status.success() { Ok(()) } else { - Err(RadrootsRuntimeManagerError::StopProcess { - pid, - details: format!("`kill {signal}` returned {status}"), - }) + Err(RadrootsRuntimeManagerError::StopProcess) } } @@ -610,34 +522,23 @@ fn force_kill_process(pid: u32) -> Result<(), RadrootsRuntimeManagerError> { .stderr(Stdio::null()) .status() .map_err(|source| RadrootsRuntimeManagerError::ExecuteProcessSignal { - pid, - signal: "taskkill".to_owned(), - source, + kind: source.kind(), })?; if status.success() { Ok(()) } else { - Err(RadrootsRuntimeManagerError::StopProcess { - pid, - details: format!("`taskkill` returned {status}"), - }) + Err(RadrootsRuntimeManagerError::StopProcess) } } #[cfg(not(any(unix, windows)))] -fn terminate_process(pid: u32) -> Result<(), RadrootsRuntimeManagerError> { - Err(RadrootsRuntimeManagerError::StopProcess { - pid, - details: "process signaling is unsupported on this platform".to_owned(), - }) +fn terminate_process(_pid: u32) -> Result<(), RadrootsRuntimeManagerError> { + Err(RadrootsRuntimeManagerError::StopProcess) } #[cfg(not(any(unix, windows)))] -fn force_kill_process(pid: u32) -> Result<(), RadrootsRuntimeManagerError> { - Err(RadrootsRuntimeManagerError::StopProcess { - pid, - details: "process signaling is unsupported on this platform".to_owned(), - }) +fn force_kill_process(_pid: u32) -> Result<(), RadrootsRuntimeManagerError> { + Err(RadrootsRuntimeManagerError::StopProcess) } #[cfg(test)] @@ -653,65 +554,67 @@ mod tests { #[cfg(unix)] use std::os::unix::fs::PermissionsExt; - #[cfg(unix)] - use serde::ser::Error as _; + use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, + }; use tempfile::tempdir; use super::{ - ExistingPathKind, apply_mode, 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, - process_running_state_from_ps_output, read_pid, read_secret_file, - remove_instance_artifacts, remove_path_from_state, remove_path_if_exists, - serialize_instance_metadata_with, set_executable_mode, set_secret_mode, signal_process, + ExistingPathKind, ManagedRuntimeArtifactName, apply_mode, 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, + process_running_state_from_ps_output, read_pid, remove_instance_artifacts, + remove_path_from_state, remove_path_if_exists, set_executable_mode, signal_process, signal_process_with, start_process, stop_process, stop_process_for_pid, terminate_process, - write_instance_metadata, write_managed_file, write_secret_file, + write_instance_config, }; use crate::error::RadrootsRuntimeManagerError; - use crate::model::{ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord}; - use crate::paths::ManagedRuntimeInstancePaths; + use crate::paths::{ManagedRuntimeInstancePaths, resolve_instance_paths, resolve_shared_paths}; fn sample_paths(root: &Path) -> ManagedRuntimeInstancePaths { - ManagedRuntimeInstancePaths { - install_dir: root.join("install"), - state_dir: root.join("state"), - logs_dir: root.join("logs"), - run_dir: root.join("run"), - secrets_dir: root.join("secrets"), - pid_file_path: root.join("run/runtime.pid"), - stdout_log_path: root.join("logs/stdout.log"), - stderr_log_path: root.join("logs/stderr.log"), - metadata_path: root.join("state/instance.toml"), + fn context(root: &Path, service: &str) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root.to_path_buf()), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new(service).expect("service"), + InstanceId::new("local").expect("instance"), + ) + .expect("context") } + + let shared = resolve_shared_paths(&context(root, "runtime-manager")); + resolve_instance_paths(&shared, &context(root, "radrootsd")) } - 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 artifact(value: &str) -> ManagedRuntimeArtifactName { + ManagedRuntimeArtifactName::new(value).expect("artifact name") } - fn sample_record(paths: &ManagedRuntimeInstancePaths) -> ManagedRuntimeInstanceRecord { - 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: Some("http://127.0.0.1:7070".to_owned()), - secret_material_ref: Some(paths.secrets_dir.join("token.txt").display().to_string()), - last_started_at: None, - last_stopped_at: None, - notes: Some("test".to_owned()), + fn assert_safe_error(err: &RadrootsRuntimeManagerError, expected: &str, forbidden: &[&str]) { + use std::error::Error as _; + + let rendered = format!("{err} {err:?}"); + assert!( + rendered.contains(expected), + "expected `{rendered}` to contain `{expected}`" + ); + for part in forbidden { + assert!( + !rendered.contains(part), + "expected `{rendered}` not to contain `{part}`" + ); } + assert!(err.source().is_none()); } #[cfg(unix)] @@ -757,19 +660,16 @@ mod tests { } #[test] - fn layout_and_metadata_helpers_write_expected_files() { + fn layout_creates_only_manager_owned_install_and_tracking_roots() { let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); ensure_instance_layout(&paths).expect("layout"); - write_managed_file(paths.state_dir.join("config.toml"), "value = true").expect("config"); - write_secret_file(paths.secrets_dir.join("token.txt"), "secret").expect("secret"); - write_instance_metadata(&paths, &sample_record(&paths)).expect("metadata"); - assert_eq!( - read_secret_file(paths.secrets_dir.join("token.txt")).expect("read secret"), - "secret" - ); - assert!(paths.metadata_path.is_file()); - assert!(paths.state_dir.join("config.toml").is_file()); + assert!(paths.install_dir().is_dir()); + assert!(paths.logs_dir().is_dir()); + assert!(paths.run_dir().is_dir()); + assert!(!paths.config_dir().exists()); + assert!(!paths.state_dir().exists()); + assert!(!paths.secrets_dir().exists()); } #[test] @@ -778,8 +678,34 @@ mod tests { let source = dir.path().join("radrootsd"); fs::write(&source, "#!/bin/sh\nexit 0\n").expect("source"); let paths = sample_paths(dir.path()); - let installed = install_binary(&source, &paths, "radrootsd").expect("install"); + let installed = install_binary(&source, &paths, &artifact("radrootsd")).expect("install"); assert!(installed.is_file()); + assert!(installed.starts_with(paths.install_dir())); + } + + #[test] + fn artifact_names_reject_absolute_parent_and_multicomponent_escapes() { + for invalid in [ + "", + "/tmp/escape", + "../escape", + "nested/escape", + r"nested\escape", + ".", + "..", + " secret", + ] { + let err = ManagedRuntimeArtifactName::new(invalid).expect_err("reject artifact name"); + if invalid.is_empty() { + assert_safe_error(&err, "artifact name is invalid", &[]); + } else { + assert_safe_error(&err, "artifact name is invalid", &[invalid]); + } + } + + let maximum = format!("a{}", "b".repeat(127)); + assert!(ManagedRuntimeArtifactName::new(&maximum).is_ok()); + assert!(ManagedRuntimeArtifactName::new(&format!("{maximum}c")).is_err()); } #[cfg(unix)] @@ -805,7 +731,8 @@ mod tests { let paths = sample_paths(dir.path()); let installed = - extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd").expect("extract"); + extract_binary_archive(&archive_path, "tar.gz", &paths, &artifact("radrootsd")) + .expect("extract"); assert!(installed.is_file()); } @@ -829,8 +756,9 @@ mod tests { 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")); + extract_binary_archive(&archive_path, "tar.gz", &paths, &artifact("radrootsd")) + .expect("extract"); + assert_eq!(installed, paths.install_dir().join("radrootsd")); } #[cfg(unix)] @@ -840,15 +768,15 @@ mod tests { let binary = dir.path().join("sleepy.sh"); fs::write(&binary, "#!/bin/sh\nexec sleep 30\n").expect("script"); let paths = sample_paths(dir.path()); - let installed = install_binary(&binary, &paths, "sleepy.sh").expect("install"); + install_binary(&binary, &paths, &artifact("sleepy.sh")).expect("install"); let envs = vec![("RADROOTS_RUNTIME_MANAGER_TEST".to_owned(), "1".to_owned())]; - let pid = start_process(&installed, &Vec::new(), &envs, &paths).expect("start"); + let pid = start_process(&paths, &artifact("sleepy.sh"), &Vec::new(), &envs).expect("start"); assert!(pid > 0); thread::sleep(Duration::from_millis(100)); - assert!(paths.pid_file_path.is_file()); + assert!(paths.pid_file_path().is_file()); assert!(process_running(&paths).expect("running")); assert!(stop_process(&paths).expect("stop")); - assert!(!paths.pid_file_path.exists()); + assert!(!paths.pid_file_path().exists()); } #[test] @@ -856,27 +784,30 @@ mod tests { let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); ensure_instance_layout(&paths).expect("layout"); + fs::create_dir_all(paths.state_dir()).expect("canonical state sentinel"); + fs::create_dir_all(paths.secrets_dir()).expect("canonical secrets sentinel"); + fs::write(paths.state_dir().join("state.sqlite"), "state").expect("state sentinel"); + fs::write(paths.secrets_dir().join("identity.secret"), "secret").expect("secret sentinel"); remove_instance_artifacts(&paths).expect("remove"); - assert!(!paths.install_dir.exists()); - assert!(!paths.state_dir.exists()); - assert!(!paths.logs_dir.exists()); - assert!(!paths.run_dir.exists()); - assert!(!paths.secrets_dir.exists()); + assert!(!paths.install_dir().exists()); + assert!(!paths.logs_dir().exists()); + assert!(!paths.run_dir().exists()); + assert!(paths.state_dir().join("state.sqlite").is_file()); + assert!(paths.secrets_dir().join("identity.secret").is_file()); } #[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"); + fs::create_dir_all(paths.install_dir().parent().expect("install parent")).expect("parent"); + fs::write(paths.install_dir(), "occupied").expect("file"); let err = ensure_instance_layout(&paths).expect_err("file path should fail"); - assert_error_contains( + assert_safe_error( &err, - &[ - paths.install_dir.to_string_lossy().as_ref(), - "create directory", - ], + "create managed runtime directory", + &[paths.install_dir().to_string_lossy().as_ref()], ); } @@ -884,18 +815,18 @@ mod tests { 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") + let err = install_binary(dir.path().join("missing"), &paths, &artifact("radrootsd")) .expect_err("missing source should fail"); - assert_error_contains( + assert_safe_error( &err, + "copy managed runtime binary", &[ dir.path().join("missing").to_string_lossy().as_ref(), paths - .install_dir + .install_dir() .join("radrootsd") .to_string_lossy() .as_ref(), - "copy runtime binary", ], ); } @@ -906,9 +837,13 @@ mod tests { let paths = sample_paths(dir.path()); let archive_path = dir.path().join("radrootsd.zip"); - let err = extract_binary_archive(&archive_path, "zip", &paths, "radrootsd") + let err = extract_binary_archive(&archive_path, "zip", &paths, &artifact("radrootsd")) .expect_err("unsupported archive format should fail"); - assert_error_contains(&err, &[archive_path.to_string_lossy().as_ref(), "zip"]); + assert_safe_error( + &err, + "archive format is unsupported", + &[archive_path.to_string_lossy().as_ref(), "zip"], + ); } #[test] @@ -917,11 +852,12 @@ mod tests { 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") + let err = extract_binary_archive(&archive_path, "tar.gz", &paths, &artifact("radrootsd")) .expect_err("missing archive should fail"); - assert_error_contains( + assert_safe_error( &err, - &[archive_path.to_string_lossy().as_ref(), "read managed file"], + "read managed runtime file", + &[archive_path.to_string_lossy().as_ref()], ); } @@ -944,17 +880,18 @@ mod tests { encoder.finish().expect("finish gzip"); let paths = sample_paths(dir.path()); - let err = extract_binary_archive(&archive_path, "tar.gz", &paths, "radrootsd") + let err = extract_binary_archive(&archive_path, "tar.gz", &paths, &artifact("radrootsd")) .expect_err("archive should not resolve missing binary"); - assert_error_contains( + assert_safe_error( &err, + "read managed runtime file", &[ paths - .install_dir + .install_dir() .join("radrootsd") .to_string_lossy() .as_ref(), - "did not produce", + archive_path.to_string_lossy().as_ref(), ], ); } @@ -967,92 +904,38 @@ mod tests { 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") + let err = extract_binary_archive(&archive_path, "tar.gz", &paths, &artifact("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 { - health_endpoint: None, - secret_material_ref: None, - notes: None, - ..sample_record(&paths) - }, - ) - .expect_err("metadata dir target should fail"); - assert_error_contains( + assert_safe_error( &err, + "unpack managed runtime archive", &[ - paths.metadata_path.to_string_lossy().as_ref(), - "write runtime instance metadata", + archive_path.to_string_lossy().as_ref(), + "not a gzip archive", ], ); } #[test] - fn serialize_instance_metadata_reports_serializer_errors() { + fn write_instance_config_is_context_bound_and_reports_redacted_errors() { let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); - let err = serialize_instance_metadata_with(&sample_record(&paths), |_| { - Err(toml::ser::Error::custom("forced serializer failure")) - }) - .expect_err("serializer should fail"); - assert_error_contains( + let config = paths.config_path(); + write_instance_config(&paths, "enabled = true").expect("write config"); + assert_eq!( + fs::read_to_string(&config).expect("read config"), + "enabled = true" + ); + assert!(!paths.secrets_dir().exists()); + + fs::remove_file(&config).expect("remove config"); + fs::create_dir(&config).expect("occupy config path"); + let err = write_instance_config(&paths, "credential = 'secret-value'") + .expect_err("directory write should fail"); + assert_safe_error( &err, - &[ - "serialize runtime instance metadata", - "forced serializer failure", - ], + "write managed runtime config", + &[config.to_string_lossy().as_ref(), "secret-value"], ); } @@ -1060,14 +943,16 @@ mod tests { 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) + let err = start_process(&paths, &artifact("missing"), &[], &[]) .expect_err("missing binary should fail"); - assert_error_contains( + assert_safe_error( &err, - &[ - dir.path().join("missing").to_string_lossy().as_ref(), - "spawn managed runtime process", - ], + "spawn managed runtime process", + &[paths + .install_dir() + .join("missing") + .to_string_lossy() + .as_ref()], ); } @@ -1077,16 +962,17 @@ mod tests { 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 paths = sample_paths(dir.path()); + fs::create_dir_all(paths.pid_file_path()).expect("occupy pid path"); + install_binary(&binary, &sample_paths(dir.path()), &artifact("sleepy.sh")) + .expect("install"); - let err = - start_process(&installed, &[], &[], &paths).expect_err("pid file write should fail"); - assert_error_contains( + let err = start_process(&paths, &artifact("sleepy.sh"), &[], &[]) + .expect_err("pid file write should fail"); + assert_safe_error( &err, - &[paths.run_dir.to_string_lossy().as_ref(), "write pid file"], + "write managed runtime pid", + &[paths.pid_file_path().to_string_lossy().as_ref()], ); } @@ -1104,12 +990,16 @@ mod tests { 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"); + 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( + assert_safe_error( &err, - &[paths.pid_file_path.to_string_lossy().as_ref(), "not-a-pid"], + "managed runtime pid is malformed", + &[ + paths.pid_file_path().to_string_lossy().as_ref(), + "not-a-pid", + ], ); } @@ -1118,10 +1008,10 @@ mod tests { 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"); + 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()); + assert!(!paths.pid_file_path().exists()); } #[test] @@ -1129,7 +1019,7 @@ mod tests { let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); ensure_instance_layout(&paths).expect("layout"); - fs::write(&paths.pid_file_path, "42").expect("write pid"); + fs::write(paths.pid_file_path(), "42").expect("write pid"); let mut polls = 0_u32; let mut is_running = |_pid| { @@ -1150,7 +1040,7 @@ mod tests { .expect("force-kill path should stop"); assert!(stopped); - assert!(!paths.pid_file_path.exists()); + assert!(!paths.pid_file_path().exists()); assert_eq!(polls, 21); } @@ -1159,7 +1049,7 @@ mod tests { let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); ensure_instance_layout(&paths).expect("layout"); - fs::write(&paths.pid_file_path, "42").expect("write pid"); + fs::write(paths.pid_file_path(), "42").expect("write pid"); let mut is_running = runtime_is_stopped; let mut terminate = ok_runtime_signal; @@ -1176,7 +1066,7 @@ mod tests { .expect("terminate poll should stop"); assert!(stopped); - assert!(!paths.pid_file_path.exists()); + assert!(!paths.pid_file_path().exists()); } #[test] @@ -1184,7 +1074,7 @@ mod tests { let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); ensure_instance_layout(&paths).expect("layout"); - fs::write(&paths.pid_file_path, "42").expect("write pid"); + fs::write(paths.pid_file_path(), "42").expect("write pid"); let mut sleeps = 0_u32; let mut is_running = runtime_is_running; @@ -1203,9 +1093,9 @@ mod tests { ) .expect_err("force-kill exhaustion should fail"); - assert_error_contains(&err, &["42", "did not exit after terminate and force-kill"]); + assert_safe_error(&err, "managed runtime process did not stop", &["42"]); assert_eq!(sleeps, 40); - assert!(paths.pid_file_path.exists()); + assert!(paths.pid_file_path().exists()); } #[test] @@ -1221,9 +1111,10 @@ mod tests { let err = ensure_parent_dir(&file_parent.join("child")).expect_err("file parent should fail"); - assert_error_contains( + assert_safe_error( &err, - &[file_parent.to_string_lossy().as_ref(), "create directory"], + "create managed runtime directory", + &[file_parent.to_string_lossy().as_ref()], ); } @@ -1251,28 +1142,31 @@ mod tests { 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( + assert_safe_error( &err, - &[bad_path.to_string_lossy().as_ref(), "open runtime log file"], + "open managed runtime log", + &[bad_path.to_string_lossy().as_ref()], ); } #[test] fn read_pid_handles_empty_missing_and_read_error_cases() { let dir = tempdir().expect("tempdir"); - let mut paths = sample_paths(dir.path()); + let 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"); + 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(); + fs::remove_file(paths.pid_file_path()).expect("remove pid file"); + fs::create_dir(paths.pid_file_path()).expect("occupy pid path"); let err = read_pid(&paths).expect_err("directory pid file should fail"); - assert_error_contains( + assert_safe_error( &err, - &[paths.run_dir.to_string_lossy().as_ref(), "read pid file"], + "read managed runtime pid", + &[paths.pid_file_path().to_string_lossy().as_ref()], ); } @@ -1307,9 +1201,10 @@ mod tests { ok_remove_path, ) .expect_err("directory removal should fail"); - assert_error_contains( + assert_safe_error( &dir_err, - &[dir_path.to_string_lossy().as_ref(), "remove managed path"], + "remove manager-owned runtime path", + &[dir_path.to_string_lossy().as_ref(), "remove path denied"], ); let file_err = remove_path_from_state( @@ -1319,9 +1214,10 @@ mod tests { deny_remove_path, ) .expect_err("file removal should fail"); - assert_error_contains( + assert_safe_error( &file_err, - &[file_path.to_string_lossy().as_ref(), "remove managed path"], + "remove manager-owned runtime path", + &[file_path.to_string_lossy().as_ref(), "remove path denied"], ); let metadata_err = remove_path_from_state( @@ -1334,11 +1230,12 @@ mod tests { ok_remove_path, ) .expect_err("metadata lookup should fail"); - assert_error_contains( + assert_safe_error( &metadata_err, + "read managed runtime file", &[ metadata_path.to_string_lossy().as_ref(), - "read managed file", + "metadata lookup failed", ], ); } @@ -1346,17 +1243,15 @@ mod tests { #[test] fn remove_pid_file_reports_directory_errors() { let dir = tempdir().expect("tempdir"); - let mut paths = sample_paths(dir.path()); + let paths = sample_paths(dir.path()); ensure_instance_layout(&paths).expect("layout"); - paths.pid_file_path = paths.run_dir.clone(); + fs::create_dir(paths.pid_file_path()).expect("occupy pid path"); let err = super::remove_pid_file(&paths).expect_err("directory pid path should fail"); - assert_error_contains( + assert_safe_error( &err, - &[ - paths.run_dir.to_string_lossy().as_ref(), - "remove managed path", - ], + "remove manager-owned runtime path", + &[paths.pid_file_path().to_string_lossy().as_ref()], ); } @@ -1369,20 +1264,15 @@ mod tests { #[cfg(unix)] #[test] - fn set_mode_helpers_report_missing_path_errors() { + fn executable_mode_reports_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( + assert_safe_error( &err, - &[missing.to_string_lossy().as_ref(), "read managed file"], + "read managed runtime file", + &[missing.to_string_lossy().as_ref()], ); } @@ -1400,7 +1290,11 @@ mod tests { )) }) .expect_err("set permissions should fail"); - assert_error_contains(&err, &[path.to_string_lossy().as_ref(), "set permissions"]); + assert_safe_error( + &err, + "set managed runtime file permissions", + &[path.to_string_lossy().as_ref(), "set permissions failed"], + ); } #[cfg(unix)] @@ -1423,9 +1317,10 @@ mod tests { restore.set_mode(0o755); fs::set_permissions(&restricted, restore).expect("restore permissions"); - assert_error_contains( + assert_safe_error( &err, - &[blocked_path.to_string_lossy().as_ref(), "read managed file"], + "read managed runtime file", + &[blocked_path.to_string_lossy().as_ref()], ); } @@ -1436,13 +1331,13 @@ mod tests { 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"]); + assert_safe_error(&err, "managed runtime process did not stop", &["999999"]); let err = force_kill_process(missing_pid).expect_err("force kill should fail"); - assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]); + assert_safe_error(&err, "managed runtime process did not stop", &["999999"]); let err = signal_process(missing_pid, "-BOGUS").expect_err("invalid signal should fail"); - assert_error_contains(&err, &[&missing_pid.to_string(), "stop pid"]); + assert_safe_error(&err, "managed runtime process did not stop", &["999999"]); } #[cfg(unix)] @@ -1455,7 +1350,11 @@ mod tests { )) }) .expect_err("signal execution should fail"); - assert_error_contains(&err, &["42", "-TERM", "kill executable missing"]); + assert_safe_error( + &err, + "signal managed runtime process", + &["42", "-TERM", "kill executable missing"], + ); } #[cfg(unix)] diff --git a/crates/runtime_manager/src/managed.rs b/crates/runtime_manager/src/managed.rs @@ -1,21 +1,81 @@ -use std::path::PathBuf; +use core::fmt; +use std::path::Path; -use radroots_runtime_paths::{ - RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimePathSelection, -}; +use radroots_runtime_paths::{RadrootsPathProfile, RuntimeContext, RuntimeContextSource}; +use crate::paths::{resolve_instance_paths, resolve_shared_paths}; +use crate::registry::{remove_instance, upsert_instance}; use crate::{ BootstrapRuntimeContract, ManagedRuntimeHealthState, ManagedRuntimeInstallState, ManagedRuntimeInstancePaths, ManagedRuntimeInstanceRecord, ManagedRuntimeInstanceRegistry, ManagementModeContract, RadrootsRuntimeManagementContract, RadrootsRuntimeManagerError, - load_registry, resolve_instance_paths, resolve_shared_paths, + load_registry, }; -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ManagedRuntimeContext { - pub contract: RadrootsRuntimeManagementContract, - pub shared_paths: crate::ManagedRuntimeSharedPaths, - pub registry: ManagedRuntimeInstanceRegistry, + contract: RadrootsRuntimeManagementContract, + manager_context: RuntimeContext, + shared_paths: crate::ManagedRuntimeSharedPaths, + registry: ManagedRuntimeInstanceRegistry, +} + +impl ManagedRuntimeContext { + #[must_use] + pub fn contract(&self) -> &RadrootsRuntimeManagementContract { + &self.contract + } + + #[must_use] + pub fn manager_context(&self) -> &RuntimeContext { + &self.manager_context + } + + #[must_use] + pub fn shared_paths(&self) -> &crate::ManagedRuntimeSharedPaths { + &self.shared_paths + } + + #[must_use] + pub fn registry(&self) -> &ManagedRuntimeInstanceRegistry { + &self.registry + } + + pub fn register_instance( + &mut self, + runtime_context: &RuntimeContext, + install_state: ManagedRuntimeInstallState, + ) -> Result<(), RadrootsRuntimeManagerError> { + ensure_context_scope(&self.manager_context, runtime_context)?; + upsert_instance( + &mut self.registry, + ManagedRuntimeInstanceRecord::new(runtime_context, install_state), + ); + Ok(()) + } + + pub fn remove_instance( + &mut self, + runtime_context: &RuntimeContext, + ) -> Result<Option<ManagedRuntimeInstanceRecord>, RadrootsRuntimeManagerError> { + ensure_context_scope(&self.manager_context, runtime_context)?; + Ok(remove_instance( + &mut self.registry, + runtime_context.service(), + runtime_context.instance(), + )) + } +} + +impl fmt::Debug for ManagedRuntimeContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ManagedRuntimeContext") + .field("manager_context", &self.manager_context) + .field("shared_paths", &self.shared_paths) + .field("registry", &"[redacted]") + .finish_non_exhaustive() + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,6 +87,7 @@ pub enum ManagedRuntimeGroup { } impl ManagedRuntimeGroup { + #[must_use] pub fn as_str(self) -> &'static str { match self { Self::ActiveManagedTarget => "active_managed_target", @@ -36,6 +97,7 @@ impl ManagedRuntimeGroup { } } + #[must_use] pub fn posture(self) -> &'static str { match self { Self::ActiveManagedTarget => "active_managed_target", @@ -46,18 +108,85 @@ impl ManagedRuntimeGroup { } } -#[derive(Debug, Clone)] +/// A sealed, internally cross-bound management target. +/// +/// ```compile_fail +/// use radroots_runtime_manager::ManagedRuntimeTarget; +/// +/// let _ = ManagedRuntimeTarget { +/// context: todo!(), +/// instance_source: todo!(), +/// runtime_group: todo!(), +/// management_mode: None, +/// mode_contract: None, +/// bootstrap: None, +/// instance_record: None, +/// predicted_paths: None, +/// }; +/// ``` +#[derive(Clone)] pub struct ManagedRuntimeTarget { - pub runtime_id: String, - pub instance_id: String, - pub instance_source: String, - pub runtime_group: ManagedRuntimeGroup, - pub management_mode: Option<String>, - pub mode_contract: Option<ManagementModeContract>, - pub bootstrap: Option<BootstrapRuntimeContract>, - pub instance_record: Option<ManagedRuntimeInstanceRecord>, - pub predicted_paths: Option<ManagedRuntimeInstancePaths>, - pub registry_path: PathBuf, + context: RuntimeContext, + instance_source: RuntimeContextSource, + runtime_group: ManagedRuntimeGroup, + management_mode: Option<String>, + mode_contract: Option<ManagementModeContract>, + bootstrap: Option<BootstrapRuntimeContract>, + instance_record: Option<ManagedRuntimeInstanceRecord>, + predicted_paths: Option<ManagedRuntimeInstancePaths>, +} + +impl ManagedRuntimeTarget { + #[must_use] + pub fn context(&self) -> &RuntimeContext { + &self.context + } + + #[must_use] + pub fn instance_source(&self) -> RuntimeContextSource { + self.instance_source + } + + #[must_use] + pub fn runtime_group(&self) -> ManagedRuntimeGroup { + self.runtime_group + } + + #[must_use] + pub fn management_mode(&self) -> Option<&str> { + self.management_mode.as_deref() + } + + #[must_use] + pub fn mode_contract(&self) -> Option<&ManagementModeContract> { + self.mode_contract.as_ref() + } + + #[must_use] + pub fn bootstrap(&self) -> Option<&BootstrapRuntimeContract> { + self.bootstrap.as_ref() + } + + #[must_use] + pub fn instance_record(&self) -> Option<&ManagedRuntimeInstanceRecord> { + self.instance_record.as_ref() + } + + #[must_use] + pub fn predicted_paths(&self) -> Option<&ManagedRuntimeInstancePaths> { + self.predicted_paths.as_ref() + } +} + +impl fmt::Debug for ManagedRuntimeTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ManagedRuntimeTarget") + .field("context", &self.context) + .field("runtime_group", &self.runtime_group) + .field("predicted_paths", &self.predicted_paths) + .finish_non_exhaustive() + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -84,6 +213,7 @@ pub enum ManagedRuntimeLifecycleAction { } impl ManagedRuntimeLifecycleAction { + #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Install => "install", @@ -100,7 +230,7 @@ impl ManagedRuntimeLifecycleAction { pub struct ManagedRuntimeStatusInspection { pub runtime_id: String, pub instance_id: String, - pub instance_source: String, + pub instance_source: RuntimeContextSource, pub runtime_group: String, pub management_posture: String, pub state: String, @@ -113,23 +243,18 @@ pub struct ManagedRuntimeStatusInspection { pub install_state: String, pub health_state: String, pub health_source: String, - pub registry_path: PathBuf, pub lifecycle_actions: Vec<String>, - pub instance_paths: Option<ManagedRuntimeInstancePaths>, - pub instance_record: Option<ManagedRuntimeInstanceRecord>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ManagedRuntimeLogsInspection { pub runtime_id: String, pub instance_id: String, - pub instance_source: String, + pub instance_source: RuntimeContextSource, pub runtime_group: String, pub state: String, pub source: String, pub detail: String, - pub stdout_log_path: Option<PathBuf>, - pub stderr_log_path: Option<PathBuf>, pub stdout_log_present: bool, pub stderr_log_present: bool, } @@ -138,13 +263,12 @@ pub struct ManagedRuntimeLogsInspection { pub struct ManagedRuntimeConfigInspection { pub runtime_id: String, pub instance_id: String, - pub instance_source: String, + pub instance_source: RuntimeContextSource, pub runtime_group: String, pub state: String, pub source: String, pub detail: String, pub config_format: Option<String>, - pub config_path: Option<PathBuf>, pub config_present: bool, pub requires_bootstrap_secret: Option<bool>, pub requires_config_bootstrap: Option<bool>, @@ -156,7 +280,7 @@ pub struct ManagedRuntimeActionInspection { pub action: String, pub runtime_id: String, pub instance_id: String, - pub instance_source: String, + pub instance_source: RuntimeContextSource, pub runtime_group: String, pub state: String, pub source: String, @@ -167,29 +291,19 @@ pub struct ManagedRuntimeActionInspection { pub fn load_management_context( contract: RadrootsRuntimeManagementContract, - resolver: &RadrootsPathResolver, - profile: RadrootsPathProfile, - overrides: &RadrootsPathOverrides, + manager_context: RuntimeContext, ) -> Result<ManagedRuntimeContext, RadrootsRuntimeManagerError> { - let mode_id = active_management_mode_for_profile(&contract, profile)?; - let shared_paths = resolve_shared_paths(&contract, resolver, profile, overrides, mode_id)?; - let registry = load_registry(&shared_paths.instance_registry_path)?; + active_management_mode_for_profile(&contract, manager_context.profile())?; + let shared_paths = resolve_shared_paths(&manager_context); + let registry = load_registry(shared_paths.instance_registry_path())?; Ok(ManagedRuntimeContext { contract, + manager_context, shared_paths, registry, }) } -pub fn load_management_context_with_selection( - contract: RadrootsRuntimeManagementContract, - resolver: &RadrootsPathResolver, - selection: &RadrootsRuntimePathSelection, -) -> Result<ManagedRuntimeContext, RadrootsRuntimeManagerError> { - let overrides = selection.caller_overrides()?; - load_management_context(contract, resolver, selection.profile, &overrides) -} - pub fn active_management_mode_for_profile( contract: &RadrootsRuntimeManagementContract, profile: RadrootsPathProfile, @@ -206,34 +320,17 @@ pub fn active_management_mode_for_profile( .any(|entry| entry == &profile_id) }) .map(|(mode_id, _)| mode_id.as_str()) - .ok_or_else(|| RadrootsRuntimeManagerError::UnsupportedProfile { - mode_id: "active".to_owned(), - profile: profile_id, - }) + .ok_or(RadrootsRuntimeManagerError::UnsupportedProfile) } pub fn resolve_runtime_target( context: &ManagedRuntimeContext, - runtime_id: &str, - requested_instance_id: Option<&str>, -) -> ManagedRuntimeTarget { + runtime_context: RuntimeContext, +) -> Result<ManagedRuntimeTarget, RadrootsRuntimeManagerError> { + ensure_context_scope(&context.manager_context, &runtime_context)?; + let runtime_id = runtime_context.service().as_str(); let runtime_group = runtime_group(&context.contract, runtime_id); let bootstrap = context.contract.bootstrap.get(runtime_id).cloned(); - let instance_id = requested_instance_id - .map(ToOwned::to_owned) - .or_else(|| { - bootstrap - .as_ref() - .map(|entry| entry.default_instance_id.clone()) - }) - .unwrap_or_else(|| "default".to_owned()); - let instance_source = if requested_instance_id.is_some() { - "command_arg".to_owned() - } else if bootstrap.is_some() { - "bootstrap_default".to_owned() - } else { - "implicit_default".to_owned() - }; let management_mode = bootstrap .as_ref() .map(|entry| entry.management_mode.clone()); @@ -244,32 +341,61 @@ pub fn resolve_runtime_target( .registry .instances .iter() - .find(|record| record.runtime_id == runtime_id && record.instance_id == instance_id) + .find(|record| record.matches_context(&runtime_context)) .cloned(); - let predicted_paths = if runtime_group == ManagedRuntimeGroup::ActiveManagedTarget { - Some(resolve_instance_paths( - &context.shared_paths, - runtime_id, - instance_id.as_str(), - )) - } else { - None - }; + let predicted_paths = matches!( + runtime_group, + ManagedRuntimeGroup::ActiveManagedTarget | ManagedRuntimeGroup::DefinedManagedTarget + ) + .then(|| resolve_instance_paths(&context.shared_paths, &runtime_context)); - ManagedRuntimeTarget { - runtime_id: runtime_id.to_owned(), - instance_id, - instance_source, + Ok(ManagedRuntimeTarget { + instance_source: runtime_context.sources().instance(), + context: runtime_context, runtime_group, management_mode, mode_contract, bootstrap, instance_record, predicted_paths, - registry_path: context.shared_paths.instance_registry_path.clone(), + }) +} + +fn ensure_context_scope( + manager: &RuntimeContext, + target: &RuntimeContext, +) -> Result<(), RadrootsRuntimeManagerError> { + if manager.profile() != target.profile() + || context_roots(manager) + .iter() + .zip(context_roots(target)) + .any(|(left, right)| left != &right) + { + return Err(RadrootsRuntimeManagerError::RuntimeContextMismatch); } + Ok(()) } +fn context_roots(context: &RuntimeContext) -> [&Path; 6] { + let paths = context.paths(); + [ + instance_root(paths.config()), + instance_root(paths.state()), + instance_root(paths.cache()), + instance_root(paths.logs()), + instance_root(paths.run()), + instance_root(paths.secrets()), + ] +} + +fn instance_root(path: &Path) -> &Path { + path.parent() + .and_then(Path::parent) + .and_then(Path::parent) + .expect("RuntimeContext service paths contain the sealed services/service/instance suffix") +} + +#[must_use] pub fn inspect_runtime_status( target: &ManagedRuntimeTarget, lifecycle_actions: &[String], @@ -279,17 +405,18 @@ pub fn inspect_runtime_status( } else { ManagedRuntimeInspectionAvailability::Success }; + let (health_state, health_source) = infer_health_state(target); ManagedRuntimeInspection { availability, view: ManagedRuntimeStatusInspection { - runtime_id: target.runtime_id.clone(), - instance_id: target.instance_id.clone(), - instance_source: target.instance_source.clone(), + runtime_id: target.context.service().to_string(), + instance_id: target.context.instance().to_string(), + instance_source: target.instance_source, runtime_group: target.runtime_group.as_str().to_owned(), management_posture: target.runtime_group.posture().to_owned(), state: status_state(target).to_owned(), - source: "runtime management contract + shared instance registry".to_owned(), + source: "runtime management contract + typed instance registry".to_owned(), detail: status_detail(target), management_mode: target.management_mode.clone(), service_manager_integration: target @@ -307,160 +434,84 @@ pub fn inspect_runtime_status( install_state: target .instance_record .as_ref() - .map(|record| install_state_label(record.install_state)) + .map(|record| install_state_label(record.install_state())) .unwrap_or_else(|| install_state_label(ManagedRuntimeInstallState::NotInstalled)) .to_owned(), - health_state: infer_health_state(target).0.to_owned(), - health_source: infer_health_state(target).1.to_owned(), - registry_path: target.registry_path.clone(), + health_state: health_state.to_owned(), + health_source: health_source.to_owned(), lifecycle_actions: if target.runtime_group == ManagedRuntimeGroup::ActiveManagedTarget { lifecycle_actions.to_vec() } else { Vec::new() }, - instance_paths: target.predicted_paths.clone(), - instance_record: target.instance_record.clone(), }, } } +#[must_use] pub fn inspect_runtime_logs( target: &ManagedRuntimeTarget, ) -> ManagedRuntimeInspection<ManagedRuntimeLogsInspection> { - let stdout_log_path = target + let availability = managed_inspection_availability(target); + let stdout_log_present = target .predicted_paths .as_ref() - .map(|paths| paths.stdout_log_path.clone()); - let stderr_log_path = target + .is_some_and(|paths| paths.stdout_log_path().exists()); + let stderr_log_present = target .predicted_paths .as_ref() - .map(|paths| paths.stderr_log_path.clone()); - let availability = match target.runtime_group { - ManagedRuntimeGroup::Unknown => ManagedRuntimeInspectionAvailability::Unconfigured, - ManagedRuntimeGroup::ActiveManagedTarget => ManagedRuntimeInspectionAvailability::Success, - ManagedRuntimeGroup::DefinedManagedTarget | ManagedRuntimeGroup::BootstrapOnly => { - if target.instance_record.is_some() { - ManagedRuntimeInspectionAvailability::Success - } else { - ManagedRuntimeInspectionAvailability::Unsupported - } - } - }; - let detail = match target.runtime_group { - ManagedRuntimeGroup::ActiveManagedTarget => { - "runtime logs report the managed stdout/stderr locations for the active managed instance" - .to_owned() - } - ManagedRuntimeGroup::DefinedManagedTarget => format!( - "runtime `{}` is only a defined future managed target; no active generic logs surface exists without a registered instance", - target.runtime_id - ), - ManagedRuntimeGroup::BootstrapOnly => format!( - "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed logs are not admitted", - target.runtime_id - ), - ManagedRuntimeGroup::Unknown => unknown_runtime_detail(target), - }; + .is_some_and(|paths| paths.stderr_log_path().exists()); ManagedRuntimeInspection { availability, view: ManagedRuntimeLogsInspection { - runtime_id: target.runtime_id.clone(), - instance_id: target.instance_id.clone(), - instance_source: target.instance_source.clone(), + runtime_id: target.context.service().to_string(), + instance_id: target.context.instance().to_string(), + instance_source: target.instance_source, runtime_group: target.runtime_group.as_str().to_owned(), - state: match availability { - ManagedRuntimeInspectionAvailability::Success => "ready".to_owned(), - ManagedRuntimeInspectionAvailability::Unconfigured => "unknown_runtime".to_owned(), - ManagedRuntimeInspectionAvailability::Unsupported => "unsupported".to_owned(), - }, - source: "runtime management contract + shared instance registry".to_owned(), - detail, - stdout_log_path: stdout_log_path.clone(), - stderr_log_path: stderr_log_path.clone(), - stdout_log_present: path_present(stdout_log_path.as_ref()).unwrap_or_else(|| { - target - .instance_record - .as_ref() - .is_some_and(|record| record.logs_path.join("stdout.log").exists()) - }), - stderr_log_present: path_present(stderr_log_path.as_ref()).unwrap_or_else(|| { - target - .instance_record - .as_ref() - .is_some_and(|record| record.logs_path.join("stderr.log").exists()) - }), + state: availability_state(availability), + source: "runtime management contract + manager-owned tracking".to_owned(), + detail: logs_detail(target), + stdout_log_present, + stderr_log_present, }, } } +#[must_use] pub fn inspect_runtime_config( target: &ManagedRuntimeTarget, ) -> ManagedRuntimeInspection<ManagedRuntimeConfigInspection> { - let availability = match target.runtime_group { - ManagedRuntimeGroup::Unknown => ManagedRuntimeInspectionAvailability::Unconfigured, - ManagedRuntimeGroup::ActiveManagedTarget => ManagedRuntimeInspectionAvailability::Success, - ManagedRuntimeGroup::DefinedManagedTarget | ManagedRuntimeGroup::BootstrapOnly => { - if target.instance_record.is_some() { - ManagedRuntimeInspectionAvailability::Success - } else { - ManagedRuntimeInspectionAvailability::Unsupported - } - } - }; - let config_path = target - .instance_record - .as_ref() - .map(|record| record.config_path.clone()); - let detail = match target.runtime_group { - ManagedRuntimeGroup::ActiveManagedTarget => { - if config_path.is_some() { - "runtime config show reports the managed config location without mutating bindings" - .to_owned() - } else { - format!( - "managed runtime `{}` has no registered instance config yet", - target.runtime_id - ) - } - } - ManagedRuntimeGroup::DefinedManagedTarget => format!( - "runtime `{}` is only a defined future managed target; generic config surfaces are not admitted without a registered instance", - target.runtime_id - ), - ManagedRuntimeGroup::BootstrapOnly => format!( - "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed config is not admitted", - target.runtime_id - ), - ManagedRuntimeGroup::Unknown => unknown_runtime_detail(target), - }; + let availability = managed_inspection_availability(target); + let config_path = target.instance_record.as_ref().and_then(|_| { + target + .predicted_paths + .as_ref() + .map(ManagedRuntimeInstancePaths::config_path) + }); + let config_present = config_path.as_deref().is_some_and(Path::exists); ManagedRuntimeInspection { availability, view: ManagedRuntimeConfigInspection { - runtime_id: target.runtime_id.clone(), - instance_id: target.instance_id.clone(), - instance_source: target.instance_source.clone(), + runtime_id: target.context.service().to_string(), + instance_id: target.context.instance().to_string(), + instance_source: target.instance_source, runtime_group: target.runtime_group.as_str().to_owned(), state: match availability { - ManagedRuntimeInspectionAvailability::Success => { - if config_path.is_some() { - "ready".to_owned() - } else { - "not_installed".to_owned() - } + ManagedRuntimeInspectionAvailability::Success if config_path.is_some() => { + "ready".to_owned() } - ManagedRuntimeInspectionAvailability::Unconfigured => "unknown_runtime".to_owned(), - ManagedRuntimeInspectionAvailability::Unsupported => "unsupported".to_owned(), + ManagedRuntimeInspectionAvailability::Success => "not_installed".to_owned(), + other => availability_state(other), }, - source: "runtime management contract + shared instance registry".to_owned(), - detail, + source: "runtime context + typed instance registry".to_owned(), + detail: config_detail(target, config_path.is_some()), config_format: target .bootstrap .as_ref() .map(|entry| entry.config_format.clone()), - config_path: config_path.clone(), - config_present: config_path.as_ref().is_some_and(|path| path.exists()), + config_present, requires_bootstrap_secret: target .bootstrap .as_ref() @@ -477,53 +528,43 @@ pub fn inspect_runtime_config( } } +#[must_use] pub fn inspect_runtime_action( target: &ManagedRuntimeTarget, action: ManagedRuntimeLifecycleAction, - detail_override: Option<String>, ) -> ManagedRuntimeInspection<ManagedRuntimeActionInspection> { - let (availability, state, detail, next_step) = match target.runtime_group { + let (availability, state, detail) = match target.runtime_group { ManagedRuntimeGroup::ActiveManagedTarget => ( ManagedRuntimeInspectionAvailability::Unsupported, "deferred", - detail_override.unwrap_or_else(|| { - format!( - "runtime {} `{}` is not supported for this managed target", - action.as_str().replace('_', " "), - target.runtime_id - ) - }), - None, + format!( + "runtime {} `{}` is not supported for this managed target", + action.as_str().replace('_', " "), + target.context.service() + ), ), ManagedRuntimeGroup::DefinedManagedTarget => ( ManagedRuntimeInspectionAvailability::Unsupported, "unsupported", - detail_override.unwrap_or_else(|| { - format!( - "runtime `{}` is only a defined future managed target; `{}` is not admitted in the current wave", - target.runtime_id, - action.as_str().replace('_', " ") - ) - }), - None, + format!( + "runtime `{}` is only a defined future managed target; `{}` is not admitted in the current wave", + target.context.service(), + action.as_str().replace('_', " ") + ), ), ManagedRuntimeGroup::BootstrapOnly => ( ManagedRuntimeInspectionAvailability::Unsupported, "unsupported", - detail_override.unwrap_or_else(|| { - format!( - "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed `{}` is not admitted", - target.runtime_id, - action.as_str().replace('_', " ") - ) - }), - None, + format!( + "runtime `{}` remains bootstrap_only; generic managed `{}` is not admitted", + target.context.service(), + action.as_str().replace('_', " ") + ), ), ManagedRuntimeGroup::Unknown => ( ManagedRuntimeInspectionAvailability::Unconfigured, "unknown_runtime", - detail_override.unwrap_or_else(|| unknown_runtime_detail(target)), - None, + unknown_runtime_detail(target), ), }; @@ -531,25 +572,51 @@ pub fn inspect_runtime_action( availability, view: ManagedRuntimeActionInspection { action: action.as_str().to_owned(), - runtime_id: target.runtime_id.clone(), - instance_id: target.instance_id.clone(), - instance_source: target.instance_source.clone(), + runtime_id: target.context.service().to_string(), + instance_id: target.context.instance().to_string(), + instance_source: target.instance_source, runtime_group: target.runtime_group.as_str().to_owned(), state: state.to_owned(), source: "generic runtime-management command family".to_owned(), detail, mutates_bindings: false, - next_step, + next_step: None, }, } } +fn managed_inspection_availability( + target: &ManagedRuntimeTarget, +) -> ManagedRuntimeInspectionAvailability { + match target.runtime_group { + ManagedRuntimeGroup::Unknown => ManagedRuntimeInspectionAvailability::Unconfigured, + ManagedRuntimeGroup::ActiveManagedTarget => ManagedRuntimeInspectionAvailability::Success, + ManagedRuntimeGroup::DefinedManagedTarget | ManagedRuntimeGroup::BootstrapOnly => { + if target.instance_record.is_some() { + ManagedRuntimeInspectionAvailability::Success + } else { + ManagedRuntimeInspectionAvailability::Unsupported + } + } + } +} + +fn availability_state(availability: ManagedRuntimeInspectionAvailability) -> String { + match availability { + ManagedRuntimeInspectionAvailability::Success => "ready", + ManagedRuntimeInspectionAvailability::Unconfigured => "unknown_runtime", + ManagedRuntimeInspectionAvailability::Unsupported => "unsupported", + } + .to_owned() +} + fn status_state(target: &ManagedRuntimeTarget) -> &'static str { match target.runtime_group { - ManagedRuntimeGroup::ActiveManagedTarget => match target.instance_record.as_ref() { - Some(record) => install_state_label(record.install_state), - None => "not_installed", - }, + ManagedRuntimeGroup::ActiveManagedTarget => target + .instance_record + .as_ref() + .map(|record| install_state_label(record.install_state())) + .unwrap_or("not_installed"), ManagedRuntimeGroup::DefinedManagedTarget => "defined_not_active", ManagedRuntimeGroup::BootstrapOnly => "bootstrap_only", ManagedRuntimeGroup::Unknown => "unknown_runtime", @@ -558,27 +625,61 @@ fn status_state(target: &ManagedRuntimeTarget) -> &'static str { fn status_detail(target: &ManagedRuntimeTarget) -> String { match target.runtime_group { - ManagedRuntimeGroup::ActiveManagedTarget => match &target.instance_record { - Some(record) => format!( - "managed runtime `{}` instance `{}` is registered with config at {}", - target.runtime_id, - target.instance_id, - record.config_path.display() - ), - None => format!( - "managed runtime `{}` has no registered instance `{}` in {}", - target.runtime_id, - target.instance_id, - target.registry_path.display() - ), - }, + ManagedRuntimeGroup::ActiveManagedTarget if target.instance_record.is_some() => format!( + "managed runtime `{}` instance `{}` is registered", + target.context.service(), + target.context.instance() + ), + ManagedRuntimeGroup::ActiveManagedTarget => format!( + "managed runtime `{}` has no registered instance `{}`", + target.context.service(), + target.context.instance() + ), ManagedRuntimeGroup::DefinedManagedTarget => format!( - "runtime `{}` is defined in the management contract but not yet admitted as an active managed target", - target.runtime_id + "runtime `{}` is defined but not yet an active managed target", + target.context.service() ), ManagedRuntimeGroup::BootstrapOnly => format!( - "runtime `{}` is bootstrap_only in the management contract and remains direct-bindable outside managed lifecycle in this wave", - target.runtime_id + "runtime `{}` is bootstrap_only in the management contract", + target.context.service() + ), + ManagedRuntimeGroup::Unknown => unknown_runtime_detail(target), + } +} + +fn logs_detail(target: &ManagedRuntimeTarget) -> String { + match target.runtime_group { + ManagedRuntimeGroup::ActiveManagedTarget => { + "runtime logs use manager-owned stdout/stderr tracking".to_owned() + } + ManagedRuntimeGroup::DefinedManagedTarget => format!( + "runtime `{}` is a defined future managed target", + target.context.service() + ), + ManagedRuntimeGroup::BootstrapOnly => format!( + "runtime `{}` is bootstrap_only; generic managed logs are not admitted", + target.context.service() + ), + ManagedRuntimeGroup::Unknown => unknown_runtime_detail(target), + } +} + +fn config_detail(target: &ManagedRuntimeTarget, registered: bool) -> String { + match target.runtime_group { + ManagedRuntimeGroup::ActiveManagedTarget if registered => { + "runtime config is derived from the validated service context".to_owned() + } + ManagedRuntimeGroup::ActiveManagedTarget => format!( + "managed runtime `{}` has no registered instance config", + target.context.service() + ), + ManagedRuntimeGroup::DefinedManagedTarget => format!( + "runtime `{}` is a defined future managed target", + target.context.service() + ), + ManagedRuntimeGroup::BootstrapOnly => format!( + "runtime `{}` is bootstrap_only; generic managed config is not admitted", + target.context.service() ), ManagedRuntimeGroup::Unknown => unknown_runtime_detail(target), } @@ -587,7 +688,7 @@ fn status_detail(target: &ManagedRuntimeTarget) -> String { fn unknown_runtime_detail(target: &ManagedRuntimeTarget) -> String { format!( "runtime `{}` is not present in the current runtime-management contract", - target.runtime_id + target.context.service() ) } @@ -598,28 +699,23 @@ fn infer_health_state(target: &ManagedRuntimeTarget) -> (&'static str, &'static "registry_absent", ); }; - if record.install_state == ManagedRuntimeInstallState::Failed { + if record.install_state() == ManagedRuntimeInstallState::Failed { return ( health_state_label(ManagedRuntimeHealthState::Failed), "registry_install_state", ); } - - if let Some(paths) = target.predicted_paths.as_ref() { - if crate::process_running(paths).unwrap_or(false) { - return ( - health_state_label(ManagedRuntimeHealthState::Running), - "process_probe", - ); - } - } else if record.run_path.join("runtime.pid").exists() { + if target + .predicted_paths + .as_ref() + .is_some_and(|paths| crate::process_running(paths).unwrap_or(false)) + { return ( health_state_label(ManagedRuntimeHealthState::Running), - "pid_file_presence", + "process_probe", ); } - - if record.install_state == ManagedRuntimeInstallState::NotInstalled { + if record.install_state() == ManagedRuntimeInstallState::NotInstalled { ( health_state_label(ManagedRuntimeHealthState::NotInstalled), "registry_install_state", @@ -652,10 +748,7 @@ fn health_state_label(state: ManagedRuntimeHealthState) -> &'static str { } } -fn path_present(path: Option<&PathBuf>) -> Option<bool> { - path.map(|value| value.exists()) -} - +#[must_use] pub fn runtime_group( contract: &RadrootsRuntimeManagementContract, runtime_id: &str, @@ -688,652 +781,300 @@ pub fn runtime_group( #[cfg(test)] mod tests { - use std::{ - fs, - path::{Path, PathBuf}, - }; + use std::fs; use radroots_runtime_paths::{ - RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, - RadrootsPlatform, RadrootsRuntimePathSelection, + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, }; use tempfile::tempdir; use super::{ - ManagedRuntimeContext, ManagedRuntimeGroup, ManagedRuntimeInspectionAvailability, - ManagedRuntimeLifecycleAction, active_management_mode_for_profile, health_state_label, - inspect_runtime_action, inspect_runtime_config, inspect_runtime_logs, - inspect_runtime_status, load_management_context, load_management_context_with_selection, + ManagedRuntimeGroup, ManagedRuntimeInspectionAvailability, ManagedRuntimeLifecycleAction, + active_management_mode_for_profile, inspect_runtime_action, inspect_runtime_config, + inspect_runtime_logs, inspect_runtime_status, load_management_context, resolve_runtime_target, runtime_group, }; - use crate::{ - ManagedRuntimeHealthState, ManagedRuntimeInstallState, ManagedRuntimeInstanceRecord, - parse_contract_str, - }; + use crate::{ManagedRuntimeInstallState, RadrootsRuntimeManagerError, parse_contract_str}; const CONTRACT: &str = r#" schema = "radroots-runtime-management" schema_version = 1 -owner_doc = "docs/execution/rcl/radroots-modular-runtime-management-bootstrap-rcl.md" +owner_doc = "owner" runtime_registry = "registry.toml" distribution_contract = "distribution.toml" capabilities_contract = "capabilities.toml" [defaults] -instance_cardinality = "single_default_instance" -managed_runtime_lookup = "shared_instance_registry" +instance_cardinality = "multiple" +managed_runtime_lookup = "typed_instance_registry" explicit_runtime_endpoint_overrides_precede_managed_instance_binding = true global_path_mutation_forbidden = true [management_clients] active = ["cli"] -defined = [] [managed_runtime_targets] active = ["radrootsd"] -defined = ["myc"] +defined = ["myc", "rhi"] bootstrap_only = ["hyf"] [lifecycle] actions = ["install", "start"] -destructive_actions = [] health_states = ["not_installed", "running"] [mode.interactive_user_managed] contract_state = "active" platforms = ["linux"] -supported_profiles = ["interactive_user", "repo_local"] +supported_profiles = ["repo_local"] service_manager_integration = false - uses_absolute_binary_paths = true - default_instance_cardinality = "single_default_instance" - - [mode.service_host_managed] - contract_state = "defined" - platforms = ["linux"] - 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" +uses_absolute_binary_paths = true +default_instance_cardinality = "multiple" + +[mode.service_host_managed] +contract_state = "defined" +platforms = ["linux"] +supported_profiles = ["service_host"] +service_manager_integration = true +uses_absolute_binary_paths = true +default_instance_cardinality = "multiple" + +[paths.interactive_user_managed] +shared_namespace = "obsolete" +instance_registry_root_class = "config" +instance_registry_rel = "obsolete" +artifact_cache_root_class = "cache" +artifact_cache_rel = "obsolete" install_root_class = "data" -install_root_rel = "shared/runtime-manager/installs" +install_root_rel = "obsolete" state_root_class = "data" -state_root_rel = "shared/runtime-manager/state" +state_root_rel = "obsolete" logs_root_class = "logs" -logs_root_rel = "shared/runtime-manager" +logs_root_rel = "obsolete" run_root_class = "run" -run_root_rel = "shared/runtime-manager" +run_root_rel = "obsolete" secrets_root_class = "secrets" -secrets_namespace_rel = "shared/runtime-manager" +secrets_namespace_rel = "obsolete" [instance_metadata] -required_fields = ["runtime_id"] -optional_fields = ["notes"] +required_fields = ["service_id", "instance_id"] - [bootstrap.radrootsd] - runtime_id = "radrootsd" - management_mode = "interactive_user_managed" - default_instance_id = "local" +[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 resolver_for_home(home_dir: PathBuf) -> RadrootsPathResolver { - RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(home_dir), - xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), - ..RadrootsHostEnvironment::default() - }, +preferred_cli_binding = true +"#; + + fn context(service: &str, instance: &str, root: &std::path::Path) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root.to_path_buf()), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new(service).expect("service"), + InstanceId::new(instance).expect("instance"), ) + .expect("context") } - fn repo_local_context(root: &Path) -> ManagedRuntimeContext { - let contract = parse_contract_str(CONTRACT).expect("contract"); - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + fn manager(root: &std::path::Path) -> super::ManagedRuntimeContext { load_management_context( - contract, - &resolver, - RadrootsPathProfile::RepoLocal, - &RadrootsPathOverrides::repo_local(root), + parse_contract_str(CONTRACT).expect("contract"), + context("runtime-manager", "default", root), ) - .expect("context") - } - - fn sample_record( - runtime_id: &str, - instance_id: &str, - install_state: ManagedRuntimeInstallState, - root: &Path, - ) -> ManagedRuntimeInstanceRecord { - let instance_root = root.join(runtime_id).join(instance_id); - ManagedRuntimeInstanceRecord { - runtime_id: runtime_id.to_owned(), - instance_id: instance_id.to_owned(), - management_mode: "interactive_user_managed".to_owned(), - install_state, - binary_path: instance_root.join("bin/runtime"), - config_path: instance_root.join("config/runtime.toml"), - logs_path: instance_root.join("logs"), - run_path: instance_root.join("run"), - installed_version: "1.0.0-alpha.1".to_owned(), - health_endpoint: Some("jsonrpc_status".to_owned()), - secret_material_ref: None, - last_started_at: None, - last_stopped_at: None, - notes: Some("managed test record".to_owned()), - } + .expect("manager") } #[test] - fn active_management_mode_matches_supported_profile() { - let contract = parse_contract_str(CONTRACT).expect("contract"); - let mode_id = - active_management_mode_for_profile(&contract, RadrootsPathProfile::InteractiveUser) - .expect("mode"); - assert_eq!(mode_id, "interactive_user_managed"); - } - - #[test] - fn active_management_mode_rejects_profiles_without_active_mode() { - let contract = parse_contract_str(CONTRACT).expect("contract"); - let err = active_management_mode_for_profile(&contract, RadrootsPathProfile::ServiceHost) - .expect_err("service host mode is defined but inactive"); - - assert!(err.to_string().contains("service_host")); - } - - #[test] - fn management_context_reports_selection_and_context_errors() { + fn manager_loads_from_its_context_and_rejects_inactive_profiles() { let dir = tempdir().expect("tempdir"); - let contract = parse_contract_str(CONTRACT).expect("contract"); - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let selection = RadrootsRuntimePathSelection::caller(RadrootsPathProfile::RepoLocal, None); - let err = load_management_context_with_selection(contract.clone(), &resolver, &selection) - .expect_err("repo local selection without root should fail"); - assert!(err.to_string().contains("repo_local")); - - let err = load_management_context( - contract.clone(), - &resolver, - RadrootsPathProfile::ServiceHost, - &RadrootsPathOverrides::default(), - ) - .expect_err("service host mode is inactive"); - assert!(err.to_string().contains("service_host")); - - let root = dir.path().join("runtime-root"); - let overrides = RadrootsPathOverrides::repo_local(&root); - fs::create_dir_all(root.join("config/shared/runtime-manager")).expect("registry parent"); - fs::create_dir(root.join("config/shared/runtime-manager/instances.toml")) - .expect("registry directory"); - let err = load_management_context( - contract, - &resolver, - RadrootsPathProfile::RepoLocal, - &overrides, - ) - .expect_err("directory registry path should fail"); - assert!(err.to_string().contains("read runtime instance registry")); - } - - #[test] - fn resolve_runtime_target_uses_bootstrap_default_instance_id() { - let contract = parse_contract_str(CONTRACT).expect("contract"); - let resolver = resolver_for_home(PathBuf::from("/home/treesap")); - let mut context = load_management_context( - contract, - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .expect("context"); - context - .registry - .instances - .push(ManagedRuntimeInstanceRecord { - runtime_id: "radrootsd".to_owned(), - instance_id: "local".to_owned(), - management_mode: "interactive_user_managed".to_owned(), - install_state: ManagedRuntimeInstallState::Configured, - binary_path: PathBuf::from("/tmp/bin/radrootsd"), - config_path: PathBuf::from("/tmp/config.toml"), - logs_path: PathBuf::from("/tmp/logs"), - run_path: PathBuf::from("/tmp/run"), - installed_version: "1.0.0-alpha.1".to_owned(), - health_endpoint: None, - secret_material_ref: None, - last_started_at: None, - last_stopped_at: None, - notes: None, - }); - - let target = resolve_runtime_target(&context, "radrootsd", None); - assert_eq!(target.instance_id, "local"); - assert_eq!(target.instance_source, "bootstrap_default"); + let manager = manager(dir.path()); assert_eq!( - target.runtime_group, - ManagedRuntimeGroup::ActiveManagedTarget + manager.manager_context().service().as_str(), + "runtime-manager" ); - assert!(target.predicted_paths.is_some()); - } - - #[test] - fn load_context_with_selection_uses_caller_path_selection() { - let dir = tempdir().expect("tempdir"); - let root = dir.path().join("runtime-root"); - let contract = parse_contract_str(CONTRACT).expect("contract"); - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let selection = RadrootsRuntimePathSelection::caller( - RadrootsPathProfile::RepoLocal, - Some(root.clone()), - ); - - let context = load_management_context_with_selection(contract, &resolver, &selection) - .expect("selection context"); - - assert_eq!( - context.shared_paths.instance_registry_path, - root.join("config/shared/runtime-manager/instances.toml") + assert!(manager.registry().instances.is_empty()); + assert!( + manager + .shared_paths() + .instance_registry_path() + .ends_with("services/runtime-manager/default/instances.toml") ); - assert!(context.registry.instances.is_empty()); - } - #[test] - fn runtime_groups_and_action_labels_cover_declared_surfaces() { let contract = parse_contract_str(CONTRACT).expect("contract"); assert_eq!( - runtime_group(&contract, "radrootsd"), - ManagedRuntimeGroup::ActiveManagedTarget - ); - assert_eq!( - runtime_group(&contract, "myc"), - ManagedRuntimeGroup::DefinedManagedTarget - ); - assert_eq!( - runtime_group(&contract, "hyf"), - ManagedRuntimeGroup::BootstrapOnly - ); - assert_eq!( - runtime_group(&contract, "unknown"), - ManagedRuntimeGroup::Unknown - ); - - assert_eq!( - ManagedRuntimeGroup::ActiveManagedTarget.as_str(), - "active_managed_target" + active_management_mode_for_profile(&contract, RadrootsPathProfile::RepoLocal) + .expect("active mode"), + "interactive_user_managed" ); - assert_eq!( - ManagedRuntimeGroup::DefinedManagedTarget.posture(), - "defined_future_target" - ); - assert_eq!( - ManagedRuntimeGroup::BootstrapOnly.posture(), - "bootstrap_only_direct_binding" + assert!( + active_management_mode_for_profile(&contract, RadrootsPathProfile::ServiceHost) + .is_err() ); - assert_eq!(ManagedRuntimeGroup::Unknown.as_str(), "unknown"); - - let actions = [ - (ManagedRuntimeLifecycleAction::Install, "install"), - (ManagedRuntimeLifecycleAction::Uninstall, "uninstall"), - (ManagedRuntimeLifecycleAction::Start, "start"), - (ManagedRuntimeLifecycleAction::Stop, "stop"), - (ManagedRuntimeLifecycleAction::Restart, "restart"), - (ManagedRuntimeLifecycleAction::ConfigSet, "config_set"), - ]; - for (action, expected) in actions { - assert_eq!(action.as_str(), expected); - } } #[test] - fn resolve_runtime_target_covers_requested_and_non_active_sources() { + fn targets_bind_exact_typed_contexts_and_multi_instance_records() { let dir = tempdir().expect("tempdir"); - let mut context = repo_local_context(dir.path()); - context.registry.instances.push(sample_record( - "myc", - "default", - ManagedRuntimeInstallState::Configured, - dir.path(), - )); - - let requested = resolve_runtime_target(&context, "radrootsd", Some("manual")); - assert_eq!(requested.instance_id, "manual"); - assert_eq!(requested.instance_source, "command_arg"); - assert_eq!( - requested.runtime_group, - ManagedRuntimeGroup::ActiveManagedTarget + let mut manager = manager(dir.path()); + let primary = context("myc", "primary", dir.path()); + let secondary = context("myc", "secondary", dir.path()); + manager + .register_instance(&primary, ManagedRuntimeInstallState::Configured) + .expect("register primary"); + + let primary_target = resolve_runtime_target(&manager, primary.clone()).expect("primary"); + let secondary_target = + resolve_runtime_target(&manager, secondary.clone()).expect("secondary"); + assert!(primary_target.instance_record.is_some()); + assert!(secondary_target.instance_record.is_none()); + assert_eq!(primary_target.context, primary); + assert_eq!(secondary_target.context, secondary); + assert_ne!( + primary_target.predicted_paths, + secondary_target.predicted_paths ); - assert!(requested.predicted_paths.is_some()); - - let defined = resolve_runtime_target(&context, "myc", None); - assert_eq!(defined.instance_id, "default"); - assert_eq!(defined.instance_source, "implicit_default"); assert_eq!( - defined.runtime_group, + primary_target.runtime_group, ManagedRuntimeGroup::DefinedManagedTarget ); - assert!(defined.predicted_paths.is_none()); - assert!(defined.instance_record.is_some()); - - let bootstrap = resolve_runtime_target(&context, "hyf", None); - assert_eq!(bootstrap.instance_source, "implicit_default"); - assert_eq!(bootstrap.runtime_group, ManagedRuntimeGroup::BootstrapOnly); - assert!(bootstrap.management_mode.is_none()); - - let unknown = resolve_runtime_target(&context, "unknown", Some("manual")); - assert_eq!(unknown.instance_id, "manual"); - assert_eq!(unknown.instance_source, "command_arg"); - assert_eq!(unknown.runtime_group, ManagedRuntimeGroup::Unknown); - assert!(unknown.predicted_paths.is_none()); + assert!(primary_target.predicted_paths.is_some()); } #[test] - fn status_inspection_covers_install_and_health_states() { - let dir = tempdir().expect("tempdir"); - let context = repo_local_context(dir.path()); - let active_missing = resolve_runtime_target(&context, "radrootsd", None); - let status = - inspect_runtime_status(&active_missing, &["install".to_owned(), "start".to_owned()]); - assert_eq!( - status.availability, - ManagedRuntimeInspectionAvailability::Success - ); - assert_eq!(status.view.state, "not_installed"); - assert_eq!(status.view.health_state, "not_installed"); - assert_eq!(status.view.health_source, "registry_absent"); - assert_eq!(status.view.lifecycle_actions, ["install", "start"]); - - let mut context = repo_local_context(dir.path()); - context.registry.instances.push(sample_record( - "radrootsd", - "local", - ManagedRuntimeInstallState::Configured, - dir.path(), + fn manager_rejects_same_identity_from_a_different_root_scope() { + let first = tempdir().expect("first"); + let second = tempdir().expect("second"); + let mut manager = manager(first.path()); + let mismatched = context("myc", "primary", second.path()); + + assert!(matches!( + manager.register_instance(&mismatched, ManagedRuntimeInstallState::Configured), + Err(RadrootsRuntimeManagerError::RuntimeContextMismatch) )); - let active_configured = resolve_runtime_target(&context, "radrootsd", None); - let configured_status = inspect_runtime_status(&active_configured, &[]); - assert_eq!(configured_status.view.state, "configured"); - assert_eq!(configured_status.view.health_state, "stopped"); - assert_eq!(configured_status.view.health_source, "pid_file_absent"); - assert_eq!(configured_status.view.install_state, "configured"); - - let predicted = active_configured - .predicted_paths - .as_ref() - .expect("predicted active paths"); - fs::create_dir_all(&predicted.run_dir).expect("run dir"); - fs::write(&predicted.pid_file_path, std::process::id().to_string()).expect("pid"); - let running_status = inspect_runtime_status(&active_configured, &[]); - assert_eq!(running_status.view.health_state, "running"); - assert_eq!(running_status.view.health_source, "process_probe"); - fs::remove_file(&predicted.pid_file_path).expect("remove pid"); - - let mut context = repo_local_context(dir.path()); - context.registry.instances.push(sample_record( - "radrootsd", - "local", - ManagedRuntimeInstallState::Failed, - dir.path(), - )); - let active_failed = resolve_runtime_target(&context, "radrootsd", None); - let failed_status = inspect_runtime_status(&active_failed, &[]); - assert_eq!(failed_status.view.state, "failed"); - assert_eq!(failed_status.view.health_state, "failed"); - assert_eq!(failed_status.view.health_source, "registry_install_state"); - - let mut context = repo_local_context(dir.path()); - context.registry.instances.push(sample_record( - "radrootsd", - "local", - ManagedRuntimeInstallState::NotInstalled, - dir.path(), - )); - let active_not_installed = resolve_runtime_target(&context, "radrootsd", None); - let not_installed_status = inspect_runtime_status(&active_not_installed, &[]); - assert_eq!(not_installed_status.view.health_state, "not_installed"); - assert_eq!( - not_installed_status.view.health_source, - "registry_install_state" - ); - - let mut context = repo_local_context(dir.path()); - let defined_record = sample_record( - "myc", - "default", - ManagedRuntimeInstallState::Installed, - dir.path(), - ); - fs::create_dir_all(&defined_record.run_path).expect("run dir"); - fs::write(defined_record.run_path.join("runtime.pid"), "42").expect("pid"); - context.registry.instances.push(defined_record); - let defined = resolve_runtime_target(&context, "myc", None); - let defined_status = inspect_runtime_status(&defined, &["install".to_owned()]); - assert_eq!(defined_status.view.state, "defined_not_active"); - assert_eq!(defined_status.view.health_state, "running"); - assert_eq!(defined_status.view.health_source, "pid_file_presence"); - assert!(defined_status.view.lifecycle_actions.is_empty()); - - let no_pid_dir = tempdir().expect("no-pid tempdir"); - let mut context = repo_local_context(no_pid_dir.path()); - context.registry.instances.push(sample_record( - "myc", - "default", - ManagedRuntimeInstallState::Installed, - no_pid_dir.path(), + assert!(matches!( + resolve_runtime_target(&manager, mismatched), + Err(RadrootsRuntimeManagerError::RuntimeContextMismatch) )); - let defined_without_pid = resolve_runtime_target(&context, "myc", None); - let defined_without_pid_status = inspect_runtime_status(&defined_without_pid, &[]); - assert_eq!(defined_without_pid_status.view.health_state, "stopped"); - assert_eq!( - defined_without_pid_status.view.health_source, - "pid_file_absent" - ); - - let bootstrap = resolve_runtime_target(&context, "hyf", None); - let bootstrap_status = inspect_runtime_status(&bootstrap, &[]); - assert_eq!(bootstrap_status.view.state, "bootstrap_only"); - assert_eq!( - bootstrap_status.view.management_posture, - "bootstrap_only_direct_binding" - ); - assert_eq!( - health_state_label(ManagedRuntimeHealthState::Starting), - "starting" - ); - assert_eq!( - health_state_label(ManagedRuntimeHealthState::Degraded), - "degraded" - ); - - let unknown = resolve_runtime_target(&context, "unknown", None); - let unknown_status = inspect_runtime_status(&unknown, &[]); - assert_eq!( - unknown_status.availability, - ManagedRuntimeInspectionAvailability::Unconfigured - ); - assert_eq!(unknown_status.view.state, "unknown_runtime"); + assert!(manager.registry().instances().is_empty()); } #[test] - fn logs_and_config_inspections_cover_availability_paths() { + fn groups_and_unknown_targets_remain_contract_controlled() { let dir = tempdir().expect("tempdir"); - let mut context = repo_local_context(dir.path()); - context.registry.instances.push(sample_record( - "radrootsd", - "local", - ManagedRuntimeInstallState::Configured, - dir.path(), - )); - let active = resolve_runtime_target(&context, "radrootsd", None); - let predicted = active.predicted_paths.as_ref().expect("predicted paths"); - fs::create_dir_all(&predicted.logs_dir).expect("predicted logs dir"); - fs::write(&predicted.stdout_log_path, "stdout").expect("stdout"); - fs::write(&predicted.stderr_log_path, "stderr").expect("stderr"); - let config_path = active - .instance_record - .as_ref() - .expect("record") - .config_path - .clone(); - fs::create_dir_all(config_path.parent().expect("config parent")).expect("config parent"); - fs::write(&config_path, "listen = true").expect("config"); - - let active_logs = inspect_runtime_logs(&active); - assert_eq!( - active_logs.availability, - ManagedRuntimeInspectionAvailability::Success - ); - assert_eq!(active_logs.view.state, "ready"); - assert!(active_logs.view.stdout_log_present); - assert!(active_logs.view.stderr_log_present); - assert!(active_logs.view.stdout_log_path.is_some()); - - let active_config = inspect_runtime_config(&active); - assert_eq!(active_config.view.state, "ready"); - assert!(active_config.view.config_present); - assert_eq!(active_config.view.config_format.as_deref(), Some("toml")); - assert_eq!(active_config.view.requires_bootstrap_secret, Some(true)); - assert_eq!(active_config.view.requires_config_bootstrap, Some(true)); - assert_eq!(active_config.view.requires_signer_provider, Some(false)); - - let empty_dir = tempdir().expect("empty tempdir"); - let empty_context = repo_local_context(empty_dir.path()); - let active_missing = resolve_runtime_target(&empty_context, "radrootsd", None); - let missing_logs = inspect_runtime_logs(&active_missing); - assert_eq!(missing_logs.view.state, "ready"); - assert!(!missing_logs.view.stdout_log_present); - assert!(!missing_logs.view.stderr_log_present); - let missing_config = inspect_runtime_config(&active_missing); - assert_eq!(missing_config.view.state, "not_installed"); - assert!(!missing_config.view.config_present); - - let mut context = repo_local_context(dir.path()); - let defined_record = sample_record( - "myc", - "default", - ManagedRuntimeInstallState::Configured, - dir.path(), - ); - fs::create_dir_all(&defined_record.logs_path).expect("defined logs dir"); - fs::write(defined_record.logs_path.join("stdout.log"), "stdout").expect("defined stdout"); - fs::create_dir_all(defined_record.config_path.parent().expect("config parent")) - .expect("defined config parent"); - fs::write(&defined_record.config_path, "enabled = true").expect("defined config"); - context.registry.instances.push(defined_record); - let defined = resolve_runtime_target(&context, "myc", None); - let defined_logs = inspect_runtime_logs(&defined); - assert_eq!( - defined_logs.availability, - ManagedRuntimeInspectionAvailability::Success - ); - assert_eq!(defined_logs.view.state, "ready"); - assert!(defined_logs.view.stdout_log_present); - assert!(!defined_logs.view.stderr_log_present); - assert!(defined_logs.view.stdout_log_path.is_none()); - let defined_config = inspect_runtime_config(&defined); - assert_eq!( - defined_config.availability, - ManagedRuntimeInspectionAvailability::Success - ); - assert_eq!(defined_config.view.state, "ready"); - assert!(defined_config.view.config_present); - - let defined_without_record = resolve_runtime_target(&empty_context, "myc", None); + let manager = manager(dir.path()); + let contract = manager.contract(); assert_eq!( - inspect_runtime_logs(&defined_without_record).availability, - ManagedRuntimeInspectionAvailability::Unsupported + runtime_group(contract, "radrootsd"), + ManagedRuntimeGroup::ActiveManagedTarget ); assert_eq!( - inspect_runtime_config(&defined_without_record).availability, - ManagedRuntimeInspectionAvailability::Unsupported + runtime_group(contract, "myc"), + ManagedRuntimeGroup::DefinedManagedTarget ); - - let bootstrap = resolve_runtime_target(&empty_context, "hyf", None); assert_eq!( - inspect_runtime_logs(&bootstrap).availability, - ManagedRuntimeInspectionAvailability::Unsupported + runtime_group(contract, "hyf"), + ManagedRuntimeGroup::BootstrapOnly ); assert_eq!( - inspect_runtime_config(&bootstrap).availability, - ManagedRuntimeInspectionAvailability::Unsupported + runtime_group(contract, "unknown"), + ManagedRuntimeGroup::Unknown ); - let unknown = resolve_runtime_target(&empty_context, "unknown", None); - assert_eq!( - inspect_runtime_logs(&unknown).availability, - ManagedRuntimeInspectionAvailability::Unconfigured - ); + let unknown = resolve_runtime_target(&manager, context("unknown", "default", dir.path())) + .expect("unknown target"); + assert!(unknown.predicted_paths.is_none()); assert_eq!( - inspect_runtime_config(&unknown).availability, + inspect_runtime_status(&unknown, &[]).availability, ManagedRuntimeInspectionAvailability::Unconfigured ); } #[test] - fn action_inspection_covers_all_group_postures() { + fn status_uses_manager_tracking_without_disclosing_paths() { let dir = tempdir().expect("tempdir"); - let context = repo_local_context(dir.path()); - let active = resolve_runtime_target(&context, "radrootsd", None); - let defined = resolve_runtime_target(&context, "myc", None); - let bootstrap = resolve_runtime_target(&context, "hyf", None); - let unknown = resolve_runtime_target(&context, "unknown", None); - - let active_install = - inspect_runtime_action(&active, ManagedRuntimeLifecycleAction::Install, None); + let mut manager = manager(dir.path()); + let service = context("radrootsd", "local", dir.path()); + manager + .register_instance(&service, ManagedRuntimeInstallState::Configured) + .expect("register service"); + let target = resolve_runtime_target(&manager, service).expect("target"); + let paths = target.predicted_paths.as_ref().expect("paths"); + fs::create_dir_all(paths.run_dir()).expect("run dir"); + fs::write(paths.pid_file_path(), std::process::id().to_string()).expect("pid"); + + let status = inspect_runtime_status(&target, &["start".to_owned()]); + assert_eq!(status.view.health_state, "running"); + assert_eq!(status.view.health_source, "process_probe"); assert_eq!( - active_install.availability, - ManagedRuntimeInspectionAvailability::Unsupported - ); - assert_eq!(active_install.view.state, "deferred"); - assert!(active_install.view.detail.contains("runtime install")); - assert!(!active_install.view.mutates_bindings); - assert!(active_install.view.next_step.is_none()); - - let overridden = inspect_runtime_action( - &active, - ManagedRuntimeLifecycleAction::ConfigSet, - Some("custom detail".to_owned()), - ); - assert_eq!(overridden.view.action, "config_set"); - assert_eq!(overridden.view.detail, "custom detail"); - - let defined_start = - inspect_runtime_action(&defined, ManagedRuntimeLifecycleAction::Start, None); - assert_eq!(defined_start.view.state, "unsupported"); - assert!( - defined_start - .view - .detail - .contains("defined future managed target") + status.view.instance_source, + RuntimeContextSource::BootstrapCli ); + let rendered = format!("{status:?}"); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + assert!(!status.view.detail.contains('/')); + } - let bootstrap_stop = - inspect_runtime_action(&bootstrap, ManagedRuntimeLifecycleAction::Stop, None); - assert_eq!(bootstrap_stop.view.state, "unsupported"); - assert!(bootstrap_stop.view.detail.contains("bootstrap_only")); + #[test] + fn log_and_config_inspections_use_manager_and_service_context_paths() { + let dir = tempdir().expect("tempdir"); + let mut manager = manager(dir.path()); + let service = context("radrootsd", "local", dir.path()); + manager + .register_instance(&service, ManagedRuntimeInstallState::Configured) + .expect("register service"); + let target = resolve_runtime_target(&manager, service).expect("target"); + let paths = target.predicted_paths.as_ref().expect("paths"); + fs::create_dir_all(paths.logs_dir()).expect("logs"); + fs::write(paths.stdout_log_path(), "stdout").expect("stdout"); + let config_path = paths.config_path(); + fs::create_dir_all(config_path.parent().expect("config parent")).expect("config parent"); + fs::write(&config_path, "enabled = true").expect("config"); + + let logs = inspect_runtime_logs(&target); + assert!(logs.view.stdout_log_present); + assert!(!logs.view.stderr_log_present); + let config = inspect_runtime_config(&target); + assert!(config.view.config_present); + assert_eq!(config.view.config_format.as_deref(), Some("toml")); + for rendered in [format!("{logs:?}"), format!("{config:?}")] { + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + } + } - let unknown_restart = - inspect_runtime_action(&unknown, ManagedRuntimeLifecycleAction::Restart, None); - assert_eq!( - unknown_restart.availability, - ManagedRuntimeInspectionAvailability::Unconfigured - ); - assert_eq!(unknown_restart.view.state, "unknown_runtime"); + #[test] + fn actions_do_not_mutate_bindings_for_any_group() { + let dir = tempdir().expect("tempdir"); + let manager = manager(dir.path()); + for (service, expected) in [ + ( + "radrootsd", + ManagedRuntimeInspectionAvailability::Unsupported, + ), + ("myc", ManagedRuntimeInspectionAvailability::Unsupported), + ("hyf", ManagedRuntimeInspectionAvailability::Unsupported), + ( + "unknown", + ManagedRuntimeInspectionAvailability::Unconfigured, + ), + ] { + let target = resolve_runtime_target(&manager, context(service, "default", dir.path())) + .expect("target"); + let action = inspect_runtime_action(&target, ManagedRuntimeLifecycleAction::ConfigSet); + assert_eq!(action.availability, expected); + assert!(!action.view.mutates_bindings); + assert!(action.view.next_step.is_none()); + } } } diff --git a/crates/runtime_manager/src/model.rs b/crates/runtime_manager/src/model.rs @@ -1,7 +1,6 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; - +use radroots_runtime_paths::{InstanceId, RuntimeContext, ServiceId}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct RadrootsRuntimeManagementContract { @@ -125,37 +124,112 @@ pub enum ManagedRuntimeHealthState { Failed, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Sealed registry state for one typed service instance. +/// +/// ```compile_fail +/// use radroots_runtime_manager::ManagedRuntimeInstanceRecord; +/// +/// let _ = ManagedRuntimeInstanceRecord { +/// service_id: todo!(), +/// instance_id: todo!(), +/// install_state: todo!(), +/// }; +/// ``` +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ManagedRuntimeInstanceRecord { - pub runtime_id: String, - pub instance_id: String, - pub management_mode: String, - pub install_state: ManagedRuntimeInstallState, - pub binary_path: PathBuf, - pub config_path: PathBuf, - pub logs_path: PathBuf, - pub run_path: PathBuf, - pub installed_version: String, - pub health_endpoint: Option<String>, - pub secret_material_ref: Option<String>, - pub last_started_at: Option<String>, - pub last_stopped_at: Option<String>, - pub notes: Option<String>, + service_id: ServiceId, + instance_id: InstanceId, + install_state: ManagedRuntimeInstallState, } +impl ManagedRuntimeInstanceRecord { + #[must_use] + pub(crate) fn new(context: &RuntimeContext, install_state: ManagedRuntimeInstallState) -> Self { + Self { + service_id: context.service().clone(), + instance_id: context.instance().clone(), + install_state, + } + } + + #[must_use] + pub fn service_id(&self) -> &ServiceId { + &self.service_id + } + + #[must_use] + pub fn instance_id(&self) -> &InstanceId { + &self.instance_id + } + + #[must_use] + pub fn install_state(&self) -> ManagedRuntimeInstallState { + self.install_state + } + + #[must_use] + pub fn matches_context(&self, context: &RuntimeContext) -> bool { + self.service_id == *context.service() && self.instance_id == *context.instance() + } +} + +impl core::fmt::Debug for ManagedRuntimeInstanceRecord { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("ManagedRuntimeInstanceRecord") + .field("service_id", &self.service_id) + .field("instance_id", &self.instance_id) + .field("install_state", &self.install_state) + .finish() + } +} + +pub const RUNTIME_INSTANCE_REGISTRY_SCHEMA: &str = "radroots.service-instance-registry"; +pub const RUNTIME_INSTANCE_REGISTRY_VERSION: u32 = 1; + +/// A sealed, schema-fixed, normalized instance registry. +/// +/// ```compile_fail +/// use radroots_runtime_manager::ManagedRuntimeInstanceRegistry; +/// +/// let _ = ManagedRuntimeInstanceRegistry { +/// schema: "wrong".to_owned(), +/// schema_version: 99, +/// instances: Vec::new(), +/// }; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ManagedRuntimeInstanceRegistry { - pub schema: String, - pub schema_version: u32, + pub(crate) schema: String, + pub(crate) schema_version: u32, #[serde(default)] - pub instances: Vec<ManagedRuntimeInstanceRecord>, + pub(crate) instances: Vec<ManagedRuntimeInstanceRecord>, +} + +impl ManagedRuntimeInstanceRegistry { + #[must_use] + pub fn schema(&self) -> &str { + &self.schema + } + + #[must_use] + pub fn schema_version(&self) -> u32 { + self.schema_version + } + + #[must_use] + pub fn instances(&self) -> &[ManagedRuntimeInstanceRecord] { + &self.instances + } } impl Default for ManagedRuntimeInstanceRegistry { fn default() -> Self { Self { - schema: "radroots_runtime-instance-registry".to_string(), - schema_version: 1, + schema: RUNTIME_INSTANCE_REGISTRY_SCHEMA.to_string(), + schema_version: RUNTIME_INSTANCE_REGISTRY_VERSION, instances: Vec::new(), } } diff --git a/crates/runtime_manager/src/paths.rs b/crates/runtime_manager/src/paths.rs @@ -1,101 +1,208 @@ -use std::path::PathBuf; +use core::fmt; +use std::path::{Path, PathBuf}; -use radroots_runtime_paths::{ - RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsPaths, -}; +use radroots_runtime_paths::{RuntimeContext, default_service_instance_artifacts}; use crate::error::RadrootsRuntimeManagerError; use crate::model::RadrootsRuntimeManagementContract; -#[derive(Debug, Clone, PartialEq, Eq)] +/// Manager-owned paths derived from the manager's validated service context. +/// +/// External callers cannot forge another root set: +/// +/// ```compile_fail +/// use std::path::PathBuf; +/// use radroots_runtime_manager::ManagedRuntimeSharedPaths; +/// +/// let _ = ManagedRuntimeSharedPaths { +/// instance_registry_path: PathBuf::from("/tmp/escape"), +/// artifact_cache_dir: PathBuf::from("/tmp/escape"), +/// install_root: PathBuf::from("/tmp/escape"), +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] pub struct ManagedRuntimeSharedPaths { - pub instance_registry_path: PathBuf, - pub artifact_cache_dir: PathBuf, - pub install_root: PathBuf, - pub state_root: PathBuf, - pub logs_root: PathBuf, - pub run_root: PathBuf, - pub secrets_root: PathBuf, + instance_registry_path: PathBuf, + artifact_cache_dir: PathBuf, + install_root: PathBuf, + logs_root: PathBuf, + run_root: PathBuf, } -#[derive(Debug, Clone, PartialEq, Eq)] +impl ManagedRuntimeSharedPaths { + #[must_use] + pub fn instance_registry_path(&self) -> &Path { + &self.instance_registry_path + } + + #[must_use] + pub fn artifact_cache_dir(&self) -> &Path { + &self.artifact_cache_dir + } + + #[must_use] + pub fn install_root(&self) -> &Path { + &self.install_root + } + + #[must_use] + pub fn logs_root(&self) -> &Path { + &self.logs_root + } + + #[must_use] + pub fn run_root(&self) -> &Path { + &self.run_root + } +} + +impl fmt::Debug for ManagedRuntimeSharedPaths { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ManagedRuntimeSharedPaths([redacted])") + } +} + +/// Operational paths for one validated service instance. +/// +/// Service-owned directories and fixed artifacts come only from the supplied +/// [`RuntimeContext`]. The manager-owned install directory comes only from its +/// own validated context. Process tracking and captured stdout/stderr remain +/// under manager-owned roots, so lifecycle removal cannot delete canonical +/// service state or secrets. +/// +/// ```compile_fail +/// use std::path::PathBuf; +/// use radroots_runtime_manager::ManagedRuntimeInstancePaths; +/// +/// let _ = ManagedRuntimeInstancePaths { +/// install_dir: PathBuf::from("/tmp/escape"), +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] pub struct ManagedRuntimeInstancePaths { - pub install_dir: PathBuf, - pub state_dir: PathBuf, - pub logs_dir: PathBuf, - pub run_dir: PathBuf, - pub secrets_dir: PathBuf, - pub pid_file_path: PathBuf, - pub stdout_log_path: PathBuf, - pub stderr_log_path: PathBuf, - pub metadata_path: PathBuf, + context: RuntimeContext, + install_dir: PathBuf, + logs_dir: PathBuf, + run_dir: PathBuf, + pid_file_path: PathBuf, + stdout_log_path: PathBuf, + stderr_log_path: PathBuf, +} + +impl ManagedRuntimeInstancePaths { + #[must_use] + pub fn context(&self) -> &RuntimeContext { + &self.context + } + + #[must_use] + pub fn install_dir(&self) -> &Path { + &self.install_dir + } + + #[must_use] + pub fn config_dir(&self) -> &Path { + self.context.paths().config() + } + + #[must_use] + pub fn state_dir(&self) -> &Path { + self.context.paths().state() + } + + #[must_use] + pub fn logs_dir(&self) -> &Path { + &self.logs_dir + } + + #[must_use] + pub fn run_dir(&self) -> &Path { + &self.run_dir + } + + #[must_use] + pub fn secrets_dir(&self) -> &Path { + self.context.paths().secrets() + } + + #[must_use] + pub fn config_path(&self) -> PathBuf { + default_service_instance_artifacts(self.context.paths()) + .config() + .to_path_buf() + } + + #[must_use] + pub fn state_database_path(&self) -> PathBuf { + default_service_instance_artifacts(self.context.paths()) + .state_database() + .to_path_buf() + } + + #[must_use] + pub fn state_lock_path(&self) -> PathBuf { + default_service_instance_artifacts(self.context.paths()) + .state_lock() + .to_path_buf() + } + + #[must_use] + pub fn admin_socket_path(&self) -> PathBuf { + default_service_instance_artifacts(self.context.paths()) + .admin_socket() + .to_path_buf() + } + + #[must_use] + pub fn pid_file_path(&self) -> &Path { + &self.pid_file_path + } + + #[must_use] + pub fn stdout_log_path(&self) -> &Path { + &self.stdout_log_path + } + + #[must_use] + pub fn stderr_log_path(&self) -> &Path { + &self.stderr_log_path + } +} + +impl fmt::Debug for ManagedRuntimeInstancePaths { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ManagedRuntimeInstancePaths([redacted])") + } } -pub fn resolve_shared_paths( - contract: &RadrootsRuntimeManagementContract, - resolver: &RadrootsPathResolver, - profile: RadrootsPathProfile, - overrides: &RadrootsPathOverrides, - mode_id: &str, -) -> Result<ManagedRuntimeSharedPaths, RadrootsRuntimeManagerError> { - ensure_profile_supported(contract, mode_id, profile)?; - let roots = resolver.resolve(profile, overrides)?; - let path_spec = contract - .paths - .get(mode_id) - .ok_or_else(|| RadrootsRuntimeManagerError::MissingPathSpec(mode_id.to_string()))?; - let root = |root_class: &str, rel: &str| root_class_path(&roots, root_class, rel); - - let instance_registry_path = root( - &path_spec.instance_registry_root_class, - &path_spec.instance_registry_rel, - )?; - let artifact_cache_dir = root( - &path_spec.artifact_cache_root_class, - &path_spec.artifact_cache_rel, - )?; - let install_root = root(&path_spec.install_root_class, &path_spec.install_root_rel)?; - let state_root = root(&path_spec.state_root_class, &path_spec.state_root_rel)?; - let logs_root = root(&path_spec.logs_root_class, &path_spec.logs_root_rel)?; - let run_root = root(&path_spec.run_root_class, &path_spec.run_root_rel)?; - let secrets_root = root( - &path_spec.secrets_root_class, - &path_spec.secrets_namespace_rel, - )?; - - Ok(ManagedRuntimeSharedPaths { - instance_registry_path, - artifact_cache_dir, - install_root, - state_root, - logs_root, - run_root, - secrets_root, - }) +#[must_use] +pub(crate) fn resolve_shared_paths(context: &RuntimeContext) -> ManagedRuntimeSharedPaths { + ManagedRuntimeSharedPaths { + instance_registry_path: context.paths().config().join("instances.toml"), + artifact_cache_dir: context.paths().cache().join("artifacts"), + install_root: context.paths().state().join("installs"), + logs_root: context.paths().logs().join("instances"), + run_root: context.paths().run().join("instances"), + } } -pub fn resolve_instance_paths( +#[must_use] +pub(crate) fn resolve_instance_paths( shared: &ManagedRuntimeSharedPaths, - runtime_id: &str, - instance_id: &str, + context: &RuntimeContext, ) -> ManagedRuntimeInstancePaths { - let suffix = PathBuf::from(runtime_id).join(instance_id); - let install_dir = shared.install_root.join(&suffix); - let state_dir = shared.state_root.join(&suffix); + let suffix = PathBuf::from(context.service().as_str()).join(context.instance().as_str()); let logs_dir = shared.logs_root.join(&suffix); let run_dir = shared.run_root.join(&suffix); - let secrets_dir = shared.secrets_root.join(&suffix); ManagedRuntimeInstancePaths { - install_dir, - state_dir: state_dir.clone(), + context: context.clone(), + install_dir: shared.install_root.join(suffix), logs_dir: logs_dir.clone(), run_dir: run_dir.clone(), - secrets_dir, pid_file_path: run_dir.join("runtime.pid"), stdout_log_path: logs_dir.join("stdout.log"), stderr_log_path: logs_dir.join("stderr.log"), - metadata_path: state_dir.join("instance.toml"), } } @@ -106,52 +213,7 @@ pub fn bootstrap_runtime<'a>( contract .bootstrap .get(runtime_id) - .ok_or_else(|| RadrootsRuntimeManagerError::UnknownBootstrapRuntime(runtime_id.to_string())) -} - -fn ensure_profile_supported( - contract: &RadrootsRuntimeManagementContract, - mode_id: &str, - profile: RadrootsPathProfile, -) -> Result<(), RadrootsRuntimeManagerError> { - let mode = contract - .mode - .get(mode_id) - .ok_or_else(|| RadrootsRuntimeManagerError::UnknownManagementMode(mode_id.to_string()))?; - let profile_id = profile.to_string(); - if mode - .supported_profiles - .iter() - .any(|entry| entry == &profile_id) - { - Ok(()) - } else { - Err(RadrootsRuntimeManagerError::UnsupportedProfile { - mode_id: mode_id.to_string(), - profile: profile_id, - }) - } -} - -fn root_class_path( - roots: &RadrootsPaths, - root_class: &str, - rel: &str, -) -> Result<PathBuf, RadrootsRuntimeManagerError> { - let base = match root_class { - "config" => &roots.config, - "data" => &roots.data, - "cache" => &roots.cache, - "logs" => &roots.logs, - "run" => &roots.run, - "secrets" => &roots.secrets, - other => { - return Err(RadrootsRuntimeManagerError::UnknownRootClass( - other.to_string(), - )); - } - }; - Ok(base.join(rel)) + .ok_or(RadrootsRuntimeManagerError::UnknownBootstrapRuntime) } #[cfg(test)] @@ -159,239 +221,152 @@ mod tests { use std::path::PathBuf; use radroots_runtime_paths::{ - RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, - RadrootsPaths, RadrootsPlatform, + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, }; - use super::{bootstrap_runtime, resolve_shared_paths, root_class_path}; - use crate::{ - ManagementPathContract, RadrootsRuntimeManagerError, - model::RadrootsRuntimeManagementContract, parse_contract_str, - }; - - const CONTRACT: &str = r#" -schema = "radroots-runtime-management" -schema_version = 1 -owner_doc = "docs/execution/rcl/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") - } + use super::{resolve_instance_paths, resolve_shared_paths}; - 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 repo_context(service: &str, instance: &str, root: &str) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(PathBuf::from(root)), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new(service).expect("service"), + InstanceId::new(instance).expect("instance"), + ) + .expect("context") } - fn linux_resolver() -> RadrootsPathResolver { - RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/treesap")), - xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), - ..RadrootsHostEnvironment::default() - }, + fn service_host_context(service: &str, instance: &str) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + RuntimeContextBootstrap::new( + RadrootsPathProfile::ServiceHost, + None, + RuntimeContextSource::SafeDefault, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new(service).expect("service"), + InstanceId::new(instance).expect("instance"), ) + .expect("context") } #[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"]); - } + fn shared_paths_derive_only_from_the_manager_context() { + let manager = repo_context("runtime-manager", "default", "/repo/.radroots"); + let paths = resolve_shared_paths(&manager); - #[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`"]); + assert_eq!( + paths.instance_registry_path(), + PathBuf::from("/repo/.radroots/config/services/runtime-manager/default/instances.toml") + ); + assert_eq!( + paths.artifact_cache_dir(), + PathBuf::from("/repo/.radroots/cache/services/runtime-manager/default/artifacts") + ); + assert_eq!( + paths.install_root(), + PathBuf::from("/repo/.radroots/data/services/runtime-manager/default/installs") + ); + assert_eq!( + format!("{paths:?}"), + "ManagedRuntimeSharedPaths([redacted])" + ); } #[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"]); - } + fn instance_paths_use_the_exact_service_context_and_fixed_artifacts() { + let shared = resolve_shared_paths(&repo_context( + "runtime-manager", + "default", + "/repo/.radroots", + )); + let service = repo_context("myc", "north", "/repo/.radroots"); + let paths = resolve_instance_paths(&shared, &service); - #[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"], + assert_eq!( + paths.install_dir(), + PathBuf::from( + "/repo/.radroots/data/services/runtime-manager/default/installs/myc/north" + ) + ); + assert_eq!( + paths.config_path(), + PathBuf::from("/repo/.radroots/config/services/myc/north/config.toml") + ); + assert_eq!( + paths.state_database_path(), + PathBuf::from("/repo/.radroots/data/services/myc/north/state.sqlite") + ); + assert_eq!( + paths.state_lock_path(), + PathBuf::from("/repo/.radroots/data/services/myc/north/state.lock") + ); + assert_eq!( + paths.admin_socket_path(), + PathBuf::from("/repo/.radroots/run/services/myc/north/admin.sock") + ); + assert_eq!( + paths.stdout_log_path(), + PathBuf::from( + "/repo/.radroots/logs/services/runtime-manager/default/instances/myc/north/stdout.log" + ) + ); + assert_eq!( + format!("{paths:?}"), + "ManagedRuntimeInstancePaths([redacted])" ); } #[test] - fn resolve_shared_paths_reports_unknown_root_class() { - let mutators: &[fn(&mut ManagementPathContract)] = &[ - |paths| paths.instance_registry_root_class = "bogus".to_string(), - |paths| paths.artifact_cache_root_class = "bogus".to_string(), - |paths| paths.install_root_class = "bogus".to_string(), - |paths| paths.state_root_class = "bogus".to_string(), - |paths| paths.logs_root_class = "bogus".to_string(), - |paths| paths.run_root_class = "bogus".to_string(), - |paths| paths.secrets_root_class = "bogus".to_string(), - ]; - - for mutate in mutators { - let mut contract = contract(); - mutate( - contract - .paths - .get_mut("interactive_user_managed") - .expect("path spec"), - ); - - 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`"]); - } + fn multi_instance_paths_cannot_cross_service_contexts() { + let shared = resolve_shared_paths(&repo_context( + "runtime-manager", + "default", + "/repo/.radroots", + )); + let north = + resolve_instance_paths(&shared, &repo_context("rhi", "north", "/repo/.radroots")); + let south = + resolve_instance_paths(&shared, &repo_context("rhi", "south", "/repo/.radroots")); + + assert_ne!(north, south); + assert!(north.state_dir().ends_with("services/rhi/north")); + assert!(south.state_dir().ends_with("services/rhi/south")); + assert!(!north.state_dir().starts_with(south.state_dir())); + assert!(!south.state_dir().starts_with(north.state_dir())); } #[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"), - }; + fn linux_service_host_paths_preserve_the_canonical_service_layout() { + let manager = service_host_context("runtime-manager", "default"); + let shared = resolve_shared_paths(&manager); + let service = service_host_context("myc", "primary"); + let paths = resolve_instance_paths(&shared, &service); 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") + shared.instance_registry_path(), + PathBuf::from("/etc/radroots/services/runtime-manager/default/instances.toml") ); assert_eq!( - root_class_path(&roots, "logs", "a/b").expect("logs root"), - PathBuf::from("/roots/logs/a/b") + paths.config_path(), + PathBuf::from("/etc/radroots/services/myc/primary/config.toml") ); assert_eq!( - root_class_path(&roots, "run", "a/b").expect("run root"), - PathBuf::from("/roots/run/a/b") + paths.state_database_path(), + PathBuf::from("/var/lib/radroots/services/myc/primary/state.sqlite") ); assert_eq!( - root_class_path(&roots, "secrets", "a/b").expect("secrets root"), - PathBuf::from("/roots/secrets/a/b") + paths.admin_socket_path(), + PathBuf::from("/run/radroots/services/myc/primary/admin.sock") ); } } diff --git a/crates/runtime_manager/src/registry.rs b/crates/runtime_manager/src/registry.rs @@ -1,8 +1,13 @@ use std::fs; use std::path::Path; +use radroots_runtime_paths::{InstanceId, ServiceId}; + use crate::error::RadrootsRuntimeManagerError; -use crate::model::{ManagedRuntimeInstanceRecord, ManagedRuntimeInstanceRegistry}; +use crate::model::{ + ManagedRuntimeInstanceRecord, ManagedRuntimeInstanceRegistry, RUNTIME_INSTANCE_REGISTRY_SCHEMA, + RUNTIME_INSTANCE_REGISTRY_VERSION, +}; pub fn load_registry( path: impl AsRef<Path>, @@ -20,18 +25,14 @@ fn load_registry_path( } Err(source) => { return Err(RadrootsRuntimeManagerError::ReadRegistry { - path: path.to_path_buf(), - source, + kind: source.kind(), }); } }; - toml::from_str::<ManagedRuntimeInstanceRegistry>(&raw).map_err(|source| { - RadrootsRuntimeManagerError::ParseRegistry { - path: path.to_path_buf(), - details: source.to_string(), - } - }) + let registry = toml::from_str::<ManagedRuntimeInstanceRegistry>(&raw) + .map_err(|_| RadrootsRuntimeManagerError::ParseRegistry)?; + normalize_registry(registry) } pub fn save_registry( @@ -55,52 +56,75 @@ fn save_registry_path_with( ) -> Result<(), RadrootsRuntimeManagerError> { ensure_registry_parent(path)?; - let raw = serializer(registry) - .map_err(|err| RadrootsRuntimeManagerError::SerializeRegistry(err.to_string()))?; + let normalized = normalize_registry(registry.clone())?; + let raw = + serializer(&normalized).map_err(|_| RadrootsRuntimeManagerError::SerializeRegistry)?; fs::write(path, raw).map_err(|source| RadrootsRuntimeManagerError::WriteRegistry { - path: path.to_path_buf(), - source, + kind: source.kind(), }) } -pub fn upsert_instance( +fn normalize_registry( + mut registry: ManagedRuntimeInstanceRegistry, +) -> Result<ManagedRuntimeInstanceRegistry, RadrootsRuntimeManagerError> { + if registry.schema != RUNTIME_INSTANCE_REGISTRY_SCHEMA { + return Err(RadrootsRuntimeManagerError::UnexpectedRegistrySchema); + } + if registry.schema_version != RUNTIME_INSTANCE_REGISTRY_VERSION { + return Err(RadrootsRuntimeManagerError::UnexpectedRegistryVersion); + } + registry.instances.sort_by(|left, right| { + left.service_id() + .cmp(right.service_id()) + .then_with(|| left.instance_id().cmp(right.instance_id())) + }); + if registry.instances.windows(2).any(|pair| { + pair[0].service_id() == pair[1].service_id() + && pair[0].instance_id() == pair[1].instance_id() + }) { + return Err(RadrootsRuntimeManagerError::DuplicateRegistryInstance); + } + Ok(registry) +} + +pub(crate) fn upsert_instance( registry: &mut ManagedRuntimeInstanceRegistry, record: ManagedRuntimeInstanceRecord, ) { if let Some(existing) = registry.instances.iter_mut().find(|existing| { - existing.runtime_id == record.runtime_id && existing.instance_id == record.instance_id + existing.service_id() == record.service_id() + && existing.instance_id() == record.instance_id() }) { *existing = record; } else { registry.instances.push(record); registry.instances.sort_by(|left, right| { - left.runtime_id - .cmp(&right.runtime_id) - .then_with(|| left.instance_id.cmp(&right.instance_id)) + left.service_id() + .cmp(right.service_id()) + .then_with(|| left.instance_id().cmp(right.instance_id())) }); } } pub fn instance<'a>( registry: &'a ManagedRuntimeInstanceRegistry, - runtime_id: &str, - instance_id: &str, + service_id: &ServiceId, + instance_id: &InstanceId, ) -> Option<&'a ManagedRuntimeInstanceRecord> { registry .instances .iter() - .find(|record| record.runtime_id == runtime_id && record.instance_id == instance_id) + .find(|record| record.service_id() == service_id && record.instance_id() == instance_id) } -pub fn remove_instance( +pub(crate) fn remove_instance( registry: &mut ManagedRuntimeInstanceRegistry, - runtime_id: &str, - instance_id: &str, + service_id: &ServiceId, + instance_id: &InstanceId, ) -> Option<ManagedRuntimeInstanceRecord> { - let index = registry - .instances - .iter() - .position(|record| record.runtime_id == runtime_id && record.instance_id == instance_id)?; + let index = registry.instances.iter().position(|record| { + record.service_id() == service_id && record.instance_id() == instance_id + })?; Some(registry.instances.remove(index)) } @@ -112,8 +136,7 @@ fn ensure_registry_parent(path: &Path) -> Result<(), RadrootsRuntimeManagerError return Ok(()); } fs::create_dir_all(parent).map_err(|source| RadrootsRuntimeManagerError::CreateRegistryParent { - path: parent.to_path_buf(), - source, + kind: source.kind(), }) } @@ -122,6 +145,10 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; + use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, + }; use serde::ser::Error as _; use tempfile::tempdir; @@ -134,26 +161,30 @@ mod tests { 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: "1.0.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 runtime_context(service_id: &str, instance_id: &str) -> RuntimeContext { + RuntimeContext::resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(PathBuf::from("/repo/.radroots")), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new(service_id).expect("service"), + InstanceId::new(instance_id).expect("instance"), + ) + .expect("context") + } + + fn sample_record(service_id: &str, instance_id: &str) -> ManagedRuntimeInstanceRecord { + let context = runtime_context(service_id, instance_id); + ManagedRuntimeInstanceRecord::new(&context, ManagedRuntimeInstallState::Configured) } fn assert_error_contains(err: &RadrootsRuntimeManagerError, parts: &[&str]) { + use std::error::Error as _; + let rendered = err.to_string(); for part in parts { assert!( @@ -161,6 +192,7 @@ mod tests { "expected `{rendered}` to contain `{part}`" ); } + assert!(err.source().is_none()); } #[test] @@ -174,29 +206,21 @@ mod tests { 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", - ], - ); + assert_error_contains(&err, &["read runtime instance registry", "is a directory"]); } #[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"); + fs::write(&path, "credential = 'secret-value'\nnot = [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", - ], - ); + assert_error_contains(&err, &["parse runtime instance registry"]); + let rendered = format!("{err} {err:?}"); + assert!(!rendered.contains("secret-value")); + assert!(!rendered.contains(path.to_string_lossy().as_ref())); } #[test] @@ -207,13 +231,7 @@ mod tests { 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", - ], - ); + assert_error_contains(&err, &["write runtime instance registry", "is a directory"]); } #[test] @@ -225,13 +243,7 @@ mod tests { 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", - ], - ); + assert_error_contains(&err, &["create runtime instance registry parent"]); } #[test] @@ -247,13 +259,7 @@ mod tests { }) .expect_err("serializer should fail"); - assert_error_contains( - &err, - &[ - "serialize runtime instance registry", - "forced registry serializer failure", - ], - ); + assert_error_contains(&err, &["serialize runtime instance registry"]); } #[test] @@ -269,16 +275,21 @@ mod tests { upsert_instance(&mut registry, sample_record("radrootsd", "a")); upsert_instance(&mut registry, sample_record("myc", "a")); - let mut replacement = sample_record("radrootsd", "b"); - replacement.installed_version = "0.2.0".to_string(); + let replacement = ManagedRuntimeInstanceRecord::new( + &runtime_context("radrootsd", "b"), + ManagedRuntimeInstallState::Failed, + ); upsert_instance(&mut registry, replacement); assert_eq!(registry.instances.len(), 3); - assert_eq!(registry.instances[0].runtime_id, "myc"); - assert_eq!(registry.instances[1].instance_id, "a"); - assert_eq!(registry.instances[2].runtime_id, "radrootsd"); - assert_eq!(registry.instances[2].instance_id, "b"); - assert_eq!(registry.instances[2].installed_version, "0.2.0"); + assert_eq!(registry.instances[0].service_id().as_str(), "myc"); + assert_eq!(registry.instances[1].instance_id().as_str(), "a"); + assert_eq!(registry.instances[2].service_id().as_str(), "radrootsd"); + assert_eq!(registry.instances[2].instance_id().as_str(), "b"); + assert_eq!( + registry.instances[2].install_state(), + ManagedRuntimeInstallState::Failed + ); } #[test] @@ -286,11 +297,94 @@ mod tests { let mut registry = ManagedRuntimeInstanceRegistry::default(); upsert_instance(&mut registry, sample_record("radrootsd", "local")); - assert!(instance(&registry, "myc", "local").is_none()); - assert!(remove_instance(&mut registry, "myc", "local").is_none()); + let myc = ServiceId::new("myc").expect("service"); + let radrootsd = ServiceId::new("radrootsd").expect("service"); + let local = InstanceId::new("local").expect("instance"); + + assert!(instance(&registry, &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"); + let removed = remove_instance(&mut registry, &radrootsd, &local).expect("remove"); + assert_eq!(removed.service_id().as_str(), "radrootsd"); assert!(registry.instances.is_empty()); } + + #[test] + fn registry_round_trip_is_typed_sorted_and_contains_no_service_paths_or_secrets() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("instances.toml"); + let mut registry = ManagedRuntimeInstanceRegistry::default(); + upsert_instance(&mut registry, sample_record("rhi", "secondary")); + upsert_instance(&mut registry, sample_record("myc", "primary")); + + save_registry(&path, &registry).expect("save registry"); + let raw = fs::read_to_string(&path).expect("read registry"); + for forbidden in [ + "binary_path", + "config_path", + "logs_path", + "run_path", + "secrets_path", + "secret_material_ref", + "/repo/", + "/etc/", + ] { + assert!(!raw.contains(forbidden), "registry leaked `{forbidden}`"); + } + + let loaded = load_registry(&path).expect("load registry"); + assert_eq!(loaded, registry); + assert_eq!(loaded.instances[0].service_id().as_str(), "myc"); + assert_eq!(loaded.instances[1].service_id().as_str(), "rhi"); + } + + #[test] + fn registry_rejects_schema_version_unknown_fields_and_duplicate_keys() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("instances.toml"); + let registry = ManagedRuntimeInstanceRegistry { + instances: vec![sample_record("myc", "primary")], + ..ManagedRuntimeInstanceRegistry::default() + }; + save_registry(&path, &registry).expect("save registry"); + let raw = fs::read_to_string(&path).expect("read registry"); + + fs::write( + &path, + raw.replace("radroots.service-instance-registry", "wrong"), + ) + .expect("write wrong schema"); + assert!(matches!( + load_registry(&path), + Err(RadrootsRuntimeManagerError::UnexpectedRegistrySchema) + )); + + fs::write( + &path, + raw.replace("schema_version = 1", "schema_version = 2"), + ) + .expect("write wrong version"); + assert!(matches!( + load_registry(&path), + Err(RadrootsRuntimeManagerError::UnexpectedRegistryVersion) + )); + + fs::write(&path, format!("{raw}\nunknown = true\n")).expect("write unknown field"); + assert!(matches!( + load_registry(&path), + Err(RadrootsRuntimeManagerError::ParseRegistry) + )); + + let duplicate = ManagedRuntimeInstanceRegistry { + instances: vec![ + sample_record("myc", "primary"), + sample_record("myc", "primary"), + ], + ..ManagedRuntimeInstanceRegistry::default() + }; + assert!(matches!( + save_registry(&path, &duplicate), + Err(RadrootsRuntimeManagerError::DuplicateRegistryInstance) + )); + } }