commit e5a23a888b61202c04e930f255fb7ca5fd369ade
parent e42b6344a3c97ad363bb49718e807e6d01736317
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Apr 2026 22:06:40 +0000
runtime: add generic runtime management commands
Diffstat:
19 files changed, 1686 insertions(+), 7 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -65,6 +65,7 @@ pub enum Command {
Order(OrderArgs),
Relay(RelayArgs),
Rpc(RpcArgs),
+ Runtime(RuntimeArgs),
Signer(SignerArgs),
Sync(SyncArgs),
}
@@ -124,6 +125,19 @@ impl Command {
RpcCommand::Status => "rpc status",
RpcCommand::Sessions => "rpc sessions",
},
+ Self::Runtime(runtime) => match &runtime.command {
+ RuntimeCommand::Install(_) => "runtime install",
+ RuntimeCommand::Uninstall(_) => "runtime uninstall",
+ RuntimeCommand::Status(_) => "runtime status",
+ RuntimeCommand::Start(_) => "runtime start",
+ RuntimeCommand::Stop(_) => "runtime stop",
+ RuntimeCommand::Restart(_) => "runtime restart",
+ RuntimeCommand::Logs(_) => "runtime logs",
+ RuntimeCommand::Config(runtime_config) => match &runtime_config.command {
+ RuntimeConfigCommand::Show(_) => "runtime config show",
+ RuntimeConfigCommand::Set(_) => "runtime config set",
+ },
+ },
Self::Signer(signer) => match signer.command {
SignerCommand::Status => "signer status",
},
@@ -398,6 +412,51 @@ pub enum RpcCommand {
}
#[derive(Debug, Clone, Args)]
+pub struct RuntimeArgs {
+ #[command(subcommand)]
+ pub command: RuntimeCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum RuntimeCommand {
+ Install(RuntimeTargetArgs),
+ Uninstall(RuntimeTargetArgs),
+ Status(RuntimeTargetArgs),
+ Start(RuntimeTargetArgs),
+ Stop(RuntimeTargetArgs),
+ Restart(RuntimeTargetArgs),
+ Logs(RuntimeTargetArgs),
+ Config(RuntimeConfigArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct RuntimeTargetArgs {
+ pub runtime: String,
+ #[arg(long)]
+ pub instance: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct RuntimeConfigArgs {
+ #[command(subcommand)]
+ pub command: RuntimeConfigCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum RuntimeConfigCommand {
+ Show(RuntimeTargetArgs),
+ Set(RuntimeConfigSetArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct RuntimeConfigSetArgs {
+ #[command(flatten)]
+ pub target: RuntimeTargetArgs,
+ pub key: String,
+ pub value: String,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct OrderArgs {
#[command(subcommand)]
pub command: OrderCommand,
@@ -454,7 +513,8 @@ mod tests {
use super::{
AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, JobWatchArgs, ListingCommand,
LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, OrderWatchArgs,
- RelayCommand, RpcCommand, SignerCommand, SyncCommand, SyncWatchArgs,
+ RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SignerCommand,
+ SyncCommand, SyncWatchArgs,
};
use crate::runtime::config::OutputFormat;
use clap::Parser;
@@ -831,6 +891,85 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]);
+ match runtime_status.command {
+ Command::Runtime(args) => match args.command {
+ RuntimeCommand::Status(target) => {
+ assert_eq!(target.runtime, "radrootsd");
+ assert!(target.instance.is_none());
+ }
+ _ => panic!("unexpected runtime subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let runtime_logs = CliArgs::parse_from([
+ "radroots",
+ "runtime",
+ "logs",
+ "radrootsd",
+ "--instance",
+ "local",
+ ]);
+ match runtime_logs.command {
+ Command::Runtime(args) => match args.command {
+ RuntimeCommand::Logs(target) => {
+ assert_eq!(target.runtime, "radrootsd");
+ assert_eq!(target.instance.as_deref(), Some("local"));
+ }
+ _ => panic!("unexpected runtime subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let runtime_config_show = CliArgs::parse_from([
+ "radroots",
+ "runtime",
+ "config",
+ "show",
+ "radrootsd",
+ ]);
+ match runtime_config_show.command {
+ Command::Runtime(args) => match args.command {
+ RuntimeCommand::Config(runtime_config) => match runtime_config.command {
+ RuntimeConfigCommand::Show(target) => {
+ assert_eq!(target.runtime, "radrootsd");
+ assert!(target.instance.is_none());
+ }
+ _ => panic!("unexpected runtime config subcommand"),
+ },
+ _ => panic!("unexpected runtime subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let runtime_config_set = CliArgs::parse_from([
+ "radroots",
+ "runtime",
+ "config",
+ "set",
+ "radrootsd",
+ "--instance",
+ "local",
+ "bridge.enabled",
+ "true",
+ ]);
+ match runtime_config_set.command {
+ Command::Runtime(args) => match args.command {
+ RuntimeCommand::Config(runtime_config) => match runtime_config.command {
+ RuntimeConfigCommand::Set(set) => {
+ assert_eq!(set.target.runtime, "radrootsd");
+ assert_eq!(set.target.instance.as_deref(), Some("local"));
+ assert_eq!(set.key, "bridge.enabled");
+ assert_eq!(set.value, "true");
+ }
+ _ => panic!("unexpected runtime config subcommand"),
+ },
+ _ => panic!("unexpected runtime subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let order_new = CliArgs::parse_from([
"radroots",
"order",
@@ -971,5 +1110,14 @@ mod tests {
let order_submit = CliArgs::parse_from(["radroots", "order", "submit", "ord_demo"]);
assert_eq!(order_submit.command.display_name(), "order submit");
assert!(order_submit.command.supports_dry_run());
+
+ let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]);
+ assert_eq!(runtime_status.command.display_name(), "runtime status");
+ assert!(runtime_status.command.supports_dry_run());
+ assert!(
+ !runtime_status
+ .command
+ .supports_output_format(OutputFormat::Ndjson)
+ );
}
}
diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs
@@ -83,6 +83,7 @@ pub fn report(
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::Doctor(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Doctor(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::Doctor(view))
}
diff --git a/src/commands/find.rs b/src/commands/find.rs
@@ -11,6 +11,7 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<CommandOutput,
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::Find(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Find(view)),
CommandDisposition::InternalError => CommandOutput::internal_error(CommandView::Find(view)),
})
}
diff --git a/src/commands/identity.rs b/src/commands/identity.rs
@@ -63,6 +63,9 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::AccountWhoami(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::AccountWhoami(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::AccountWhoami(view))
}
diff --git a/src/commands/listing.rs b/src/commands/listing.rs
@@ -28,6 +28,9 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput
crate::domain::runtime::CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::ListingGet(view))
}
+ crate::domain::runtime::CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::ListingGet(view))
+ }
crate::domain::runtime::CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::ListingGet(view))
}
@@ -50,6 +53,9 @@ pub fn publish(
crate::domain::runtime::CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::ListingMutation(view))
}
+ crate::domain::runtime::CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::ListingMutation(view))
+ }
crate::domain::runtime::CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::ListingMutation(view))
}
@@ -71,6 +77,9 @@ pub fn update(
crate::domain::runtime::CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::ListingMutation(view))
}
+ crate::domain::runtime::CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::ListingMutation(view))
+ }
crate::domain::runtime::CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::ListingMutation(view))
}
@@ -92,6 +101,9 @@ pub fn archive(
crate::domain::runtime::CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::ListingMutation(view))
}
+ crate::domain::runtime::CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::ListingMutation(view))
+ }
crate::domain::runtime::CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::ListingMutation(view))
}
diff --git a/src/commands/local.rs b/src/commands/local.rs
@@ -19,6 +19,9 @@ pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::LocalStatus(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::LocalStatus(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::LocalStatus(view))
}
@@ -38,6 +41,9 @@ pub fn backup(
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::LocalBackup(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::LocalBackup(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::LocalBackup(view))
}
@@ -57,6 +63,9 @@ pub fn export(
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::LocalExport(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::LocalExport(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::LocalExport(view))
}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -15,7 +15,8 @@ pub mod sync;
use crate::cli::{
AccountCommand, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, MycCommand,
- NetCommand, OrderCommand, RelayCommand, RpcCommand, SignerCommand, SyncCommand,
+ NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand,
+ SignerCommand, SyncCommand,
};
use crate::domain::runtime::{CommandOutput, CommandView};
use crate::runtime::RuntimeError;
@@ -89,6 +90,19 @@ pub fn dispatch(
RpcCommand::Status => Ok(rpc::status(config)),
RpcCommand::Sessions => Ok(rpc::sessions(config)),
},
+ Command::Runtime(runtime_command) => match &runtime_command.command {
+ RuntimeCommand::Install(args) => runtime::install(config, args),
+ RuntimeCommand::Uninstall(args) => runtime::uninstall(config, args),
+ RuntimeCommand::Status(args) => runtime::status(config, args),
+ RuntimeCommand::Start(args) => runtime::start(config, args),
+ RuntimeCommand::Stop(args) => runtime::stop(config, args),
+ RuntimeCommand::Restart(args) => runtime::restart(config, args),
+ RuntimeCommand::Logs(args) => runtime::logs(config, args),
+ RuntimeCommand::Config(runtime_config) => match &runtime_config.command {
+ RuntimeConfigCommand::Show(args) => runtime::config_show(config, logging, args),
+ RuntimeConfigCommand::Set(args) => runtime::config_set(config, args),
+ },
+ },
Command::Sync(sync) => match &sync.command {
SyncCommand::Status => sync::status(config),
SyncCommand::Pull => sync::pull(config),
diff --git a/src/commands/myc.rs b/src/commands/myc.rs
@@ -11,6 +11,7 @@ pub fn status(config: &RuntimeConfig) -> CommandOutput {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::MycStatus(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::MycStatus(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::MycStatus(view))
}
diff --git a/src/commands/net.rs b/src/commands/net.rs
@@ -13,6 +13,7 @@ pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::NetStatus(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::NetStatus(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::NetStatus(view))
}
diff --git a/src/commands/order.rs b/src/commands/order.rs
@@ -13,6 +13,7 @@ pub fn new(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<CommandOutput,
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderNew(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderNew(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderNew(view))
}
@@ -29,6 +30,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderGet(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderGet(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderGet(view))
}
@@ -45,6 +47,7 @@ pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderList(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderList(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderList(view))
}
@@ -64,6 +67,9 @@ pub fn submit(
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderSubmit(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::OrderSubmit(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderSubmit(view))
}
@@ -80,6 +86,9 @@ pub fn watch(config: &RuntimeConfig, args: &OrderWatchArgs) -> Result<CommandOut
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderWatch(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::OrderWatch(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderWatch(view))
}
@@ -96,6 +105,9 @@ pub fn cancel(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOut
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderCancel(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::OrderCancel(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderCancel(view))
}
@@ -112,6 +124,9 @@ pub fn history(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::OrderHistory(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::OrderHistory(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::OrderHistory(view))
}
diff --git a/src/commands/relay.rs b/src/commands/relay.rs
@@ -12,6 +12,7 @@ pub fn list(config: &RuntimeConfig) -> CommandOutput {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::RelayList(view))
}
+ CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::RelayList(view)),
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::RelayList(view))
}
diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs
@@ -1,18 +1,23 @@
use crate::domain::runtime::{
AccountRuntimeView, AccountSecretRuntimeView, CapabilityBindingRuntimeView,
- ConfigFilesRuntimeView, ConfigShowView, HyfProviderRuntimeView, HyfRuntimeView,
- LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, MigrationRuntimeView,
- MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView,
+ CommandOutput, CommandView, ConfigFilesRuntimeView, ConfigShowView, HyfProviderRuntimeView,
+ HyfRuntimeView, LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView,
+ MigrationRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView,
ResolvedProviderRuntimeView, RpcRuntimeView, SignerRuntimeView, WorkflowRuntimeView,
WritePlaneRuntimeView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
use crate::runtime::logging::LoggingState;
+use crate::runtime::management::{
+ RuntimeCommandAvailability, RuntimeConfigMutationRequest, RuntimeLifecycleAction,
+ inspect_action, inspect_config_set, inspect_config_show, inspect_logs, inspect_status,
+};
use crate::runtime::provider::{
resolve_capability_providers, resolve_hyf_provider, resolve_workflow_provider,
resolve_write_plane_provider,
};
+use crate::cli::{RuntimeConfigSetArgs, RuntimeTargetArgs};
pub fn show(
config: &RuntimeConfig,
@@ -188,6 +193,140 @@ pub fn show(
})
}
+pub fn status(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_status(config, args.runtime.as_str(), args.instance.as_deref())?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeStatus(inspection.view),
+ ))
+}
+
+pub fn install(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_action(
+ config,
+ args.runtime.as_str(),
+ args.instance.as_deref(),
+ RuntimeLifecycleAction::Install,
+ )?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeAction(inspection.view),
+ ))
+}
+
+pub fn uninstall(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_action(
+ config,
+ args.runtime.as_str(),
+ args.instance.as_deref(),
+ RuntimeLifecycleAction::Uninstall,
+ )?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeAction(inspection.view),
+ ))
+}
+
+pub fn start(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_action(
+ config,
+ args.runtime.as_str(),
+ args.instance.as_deref(),
+ RuntimeLifecycleAction::Start,
+ )?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeAction(inspection.view),
+ ))
+}
+
+pub fn stop(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_action(
+ config,
+ args.runtime.as_str(),
+ args.instance.as_deref(),
+ RuntimeLifecycleAction::Stop,
+ )?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeAction(inspection.view),
+ ))
+}
+
+pub fn restart(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_action(
+ config,
+ args.runtime.as_str(),
+ args.instance.as_deref(),
+ RuntimeLifecycleAction::Restart,
+ )?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeAction(inspection.view),
+ ))
+}
+
+pub fn logs(
+ config: &RuntimeConfig,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_logs(config, args.runtime.as_str(), args.instance.as_deref())?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeLogs(inspection.view),
+ ))
+}
+
+pub fn config_show(
+ config: &RuntimeConfig,
+ _logging: &LoggingState,
+ args: &RuntimeTargetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection =
+ inspect_config_show(config, args.runtime.as_str(), args.instance.as_deref())?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeConfigShow(inspection.view),
+ ))
+}
+
+pub fn config_set(
+ config: &RuntimeConfig,
+ args: &RuntimeConfigSetArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let inspection = inspect_config_set(
+ config,
+ &RuntimeConfigMutationRequest {
+ runtime_id: args.target.runtime.clone(),
+ instance_id: args.target.instance.clone(),
+ key: args.key.clone(),
+ value: args.value.clone(),
+ },
+ )?;
+ Ok(command_output(
+ inspection.availability,
+ CommandView::RuntimeAction(inspection.view),
+ ))
+}
+
fn migration_runtime_view(config: &RuntimeConfig) -> MigrationRuntimeView {
let report = &config.migration.report;
let detected_legacy_paths = report
@@ -222,3 +361,11 @@ fn migration_runtime_view(config: &RuntimeConfig) -> MigrationRuntimeView {
actions,
}
}
+
+fn command_output(availability: RuntimeCommandAvailability, view: CommandView) -> CommandOutput {
+ match availability {
+ RuntimeCommandAvailability::Success => CommandOutput::success(view),
+ RuntimeCommandAvailability::Unconfigured => CommandOutput::unconfigured(view),
+ RuntimeCommandAvailability::Unsupported => CommandOutput::unsupported(view),
+ }
+}
diff --git a/src/commands/signer.rs b/src/commands/signer.rs
@@ -12,6 +12,9 @@ pub fn status(config: &RuntimeConfig) -> CommandOutput {
CommandDisposition::ExternalUnavailable => {
CommandOutput::external_unavailable(CommandView::SignerStatus(view))
}
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::SignerStatus(view))
+ }
CommandDisposition::InternalError => {
CommandOutput::internal_error(CommandView::SignerStatus(view))
}
diff --git a/src/commands/sync.rs b/src/commands/sync.rs
@@ -40,6 +40,7 @@ fn output_from_disposition(disposition: CommandDisposition, view: CommandView) -
CommandDisposition::Success => CommandOutput::success(view),
CommandDisposition::Unconfigured => CommandOutput::unconfigured(view),
CommandDisposition::ExternalUnavailable => CommandOutput::external_unavailable(view),
+ CommandDisposition::Unsupported => CommandOutput::unsupported(view),
CommandDisposition::InternalError => CommandOutput::internal_error(view),
}
}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -31,6 +31,13 @@ impl CommandOutput {
}
}
+ pub fn unsupported(view: CommandView) -> Self {
+ Self {
+ disposition: CommandDisposition::Unsupported,
+ view,
+ }
+ }
+
pub fn internal_error(view: CommandView) -> Self {
Self {
disposition: CommandDisposition::InternalError,
@@ -52,6 +59,7 @@ pub enum CommandDisposition {
Success,
Unconfigured,
ExternalUnavailable,
+ Unsupported,
InternalError,
}
@@ -61,6 +69,7 @@ impl CommandDisposition {
Self::Success => ExitCode::SUCCESS,
Self::Unconfigured => ExitCode::from(3),
Self::ExternalUnavailable => ExitCode::from(4),
+ Self::Unsupported => ExitCode::from(5),
Self::InternalError => ExitCode::from(1),
}
}
@@ -98,6 +107,10 @@ pub enum CommandView {
RpcSessions(RpcSessionsView),
RpcStatus(RpcStatusView),
RelayList(RelayListView),
+ RuntimeAction(RuntimeActionView),
+ RuntimeConfigShow(RuntimeManagedConfigView),
+ RuntimeLogs(RuntimeLogsView),
+ RuntimeStatus(RuntimeStatusView),
SignerStatus(SignerStatusView),
SyncPull(SyncActionView),
SyncPush(SyncActionView),
@@ -128,6 +141,123 @@ pub struct ConfigShowView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeActionView {
+ pub action: String,
+ pub runtime_id: String,
+ pub instance_id: String,
+ pub instance_source: String,
+ pub runtime_group: String,
+ pub state: String,
+ pub source: String,
+ pub detail: String,
+ pub mutates_bindings: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_step: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeManagedConfigView {
+ pub runtime_id: String,
+ pub instance_id: String,
+ pub instance_source: String,
+ pub runtime_group: String,
+ pub state: String,
+ pub source: String,
+ pub detail: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub config_format: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub config_path: Option<String>,
+ pub config_present: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub requires_bootstrap_secret: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub requires_config_bootstrap: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub requires_signer_provider: Option<bool>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeLogsView {
+ pub runtime_id: String,
+ pub instance_id: String,
+ pub instance_source: String,
+ pub runtime_group: String,
+ pub state: String,
+ pub source: String,
+ pub detail: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub stdout_log_path: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub stderr_log_path: Option<String>,
+ pub stdout_log_present: bool,
+ pub stderr_log_present: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeStatusView {
+ pub runtime_id: String,
+ pub instance_id: String,
+ pub instance_source: String,
+ pub runtime_group: String,
+ pub management_posture: String,
+ pub state: String,
+ pub source: String,
+ pub detail: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub management_mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub service_manager_integration: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub uses_absolute_binary_paths: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub preferred_cli_binding: Option<bool>,
+ pub install_state: String,
+ pub health_state: String,
+ pub health_source: String,
+ pub registry_path: String,
+ pub lifecycle_actions: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub instance_paths: Option<RuntimeInstancePathsView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub instance_record: Option<RuntimeInstanceRecordView>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeInstancePathsView {
+ pub install_dir: String,
+ pub state_dir: String,
+ pub logs_dir: String,
+ pub run_dir: String,
+ pub secrets_dir: String,
+ pub pid_file_path: String,
+ pub stdout_log_path: String,
+ pub stderr_log_path: String,
+ pub metadata_path: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeInstanceRecordView {
+ pub management_mode: String,
+ pub install_state: String,
+ pub binary_path: String,
+ pub config_path: String,
+ pub logs_path: String,
+ pub run_path: String,
+ pub installed_version: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub health_endpoint: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub secret_material_ref: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_started_at: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_stopped_at: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub notes: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct MigrationRuntimeView {
pub posture: String,
pub state: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -6,7 +6,8 @@ use crate::domain::runtime::{
ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView,
LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView,
OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView,
- RelayListView, RpcSessionsView, RpcStatusView, SyncActionView, SyncStatusView, SyncWatchView,
+ RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView,
+ RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -151,6 +152,18 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::RelayList(view) => {
render_relay_list(stdout, view)?;
}
+ CommandView::RuntimeAction(view) => {
+ render_runtime_action(stdout, view)?;
+ }
+ CommandView::RuntimeConfigShow(view) => {
+ render_runtime_config_show(stdout, view)?;
+ }
+ CommandView::RuntimeLogs(view) => {
+ render_runtime_logs(stdout, view)?;
+ }
+ CommandView::RuntimeStatus(view) => {
+ render_runtime_status(stdout, view)?;
+ }
CommandView::SignerStatus(view) => {
write_context(
stdout,
@@ -328,6 +341,22 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::RuntimeAction(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::RuntimeConfigShow(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::RuntimeLogs(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::RuntimeStatus(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::SignerStatus(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -739,6 +768,191 @@ fn render_config_show(
Ok(())
}
+fn render_runtime_action(
+ stdout: &mut dyn Write,
+ view: &RuntimeActionView,
+) -> Result<(), RuntimeError> {
+ write_context(
+ stdout,
+ format!("runtime · {} · {}", view.runtime_id, view.action.replace('_', " ")).as_str(),
+ )?;
+ render_pairs(
+ stdout,
+ "runtime",
+ &[
+ ("runtime", view.runtime_id.as_str()),
+ ("instance", view.instance_id.as_str()),
+ ("instance source", view.instance_source.as_str()),
+ ("group", view.runtime_group.as_str()),
+ ("state", view.state.as_str()),
+ ("mutates bindings", yes_no(view.mutates_bindings)),
+ ],
+ )?;
+ writeln!(stdout, "detail: {}", view.detail)?;
+ if let Some(next_step) = &view.next_step {
+ writeln!(stdout, "next step: {next_step}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ Ok(())
+}
+
+fn render_runtime_config_show(
+ stdout: &mut dyn Write,
+ view: &RuntimeManagedConfigView,
+) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("runtime · {} · config", view.runtime_id).as_str())?;
+ let mut rows = vec![
+ ("runtime", view.runtime_id.as_str()),
+ ("instance", view.instance_id.as_str()),
+ ("instance source", view.instance_source.as_str()),
+ ("group", view.runtime_group.as_str()),
+ ("state", view.state.as_str()),
+ ("config present", yes_no(view.config_present)),
+ ];
+ if let Some(config_format) = &view.config_format {
+ rows.push(("config format", config_format.as_str()));
+ }
+ if let Some(config_path) = &view.config_path {
+ rows.push(("config path", config_path.as_str()));
+ }
+ if let Some(requires_bootstrap_secret) = view.requires_bootstrap_secret {
+ rows.push((
+ "requires bootstrap secret",
+ yes_no(requires_bootstrap_secret),
+ ));
+ }
+ if let Some(requires_config_bootstrap) = view.requires_config_bootstrap {
+ rows.push((
+ "requires config bootstrap",
+ yes_no(requires_config_bootstrap),
+ ));
+ }
+ if let Some(requires_signer_provider) = view.requires_signer_provider {
+ rows.push((
+ "requires signer provider",
+ yes_no(requires_signer_provider),
+ ));
+ }
+ render_pairs(stdout, "runtime config", rows.as_slice())?;
+ writeln!(stdout, "detail: {}", view.detail)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ Ok(())
+}
+
+fn render_runtime_logs(
+ stdout: &mut dyn Write,
+ view: &RuntimeLogsView,
+) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("runtime · {} · logs", view.runtime_id).as_str())?;
+ let mut rows = vec![
+ ("runtime", view.runtime_id.as_str()),
+ ("instance", view.instance_id.as_str()),
+ ("instance source", view.instance_source.as_str()),
+ ("group", view.runtime_group.as_str()),
+ ("state", view.state.as_str()),
+ ("stdout present", yes_no(view.stdout_log_present)),
+ ("stderr present", yes_no(view.stderr_log_present)),
+ ];
+ if let Some(stdout_log_path) = &view.stdout_log_path {
+ rows.push(("stdout log", stdout_log_path.as_str()));
+ }
+ if let Some(stderr_log_path) = &view.stderr_log_path {
+ rows.push(("stderr log", stderr_log_path.as_str()));
+ }
+ render_pairs(stdout, "runtime logs", rows.as_slice())?;
+ writeln!(stdout, "detail: {}", view.detail)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ Ok(())
+}
+
+fn render_runtime_status(
+ stdout: &mut dyn Write,
+ view: &RuntimeStatusView,
+) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("runtime · {} · status", view.runtime_id).as_str())?;
+ let mut rows = vec![
+ ("runtime", view.runtime_id.as_str()),
+ ("instance", view.instance_id.as_str()),
+ ("instance source", view.instance_source.as_str()),
+ ("group", view.runtime_group.as_str()),
+ ("posture", view.management_posture.as_str()),
+ ("state", view.state.as_str()),
+ ("install state", view.install_state.as_str()),
+ ("health state", view.health_state.as_str()),
+ ("health source", view.health_source.as_str()),
+ ("registry", view.registry_path.as_str()),
+ ];
+ if let Some(mode) = &view.management_mode {
+ rows.push(("management mode", mode.as_str()));
+ }
+ if let Some(service_manager_integration) = view.service_manager_integration {
+ rows.push((
+ "service manager integration",
+ yes_no(service_manager_integration),
+ ));
+ }
+ if let Some(uses_absolute_binary_paths) = view.uses_absolute_binary_paths {
+ rows.push((
+ "uses absolute binary paths",
+ yes_no(uses_absolute_binary_paths),
+ ));
+ }
+ if let Some(preferred_cli_binding) = view.preferred_cli_binding {
+ rows.push(("preferred cli binding", yes_no(preferred_cli_binding)));
+ }
+ render_pairs(stdout, "runtime status", rows.as_slice())?;
+ writeln!(stdout, "detail: {}", view.detail)?;
+ if let Some(instance_paths) = &view.instance_paths {
+ render_pairs(
+ stdout,
+ "instance paths",
+ &[
+ ("install dir", instance_paths.install_dir.as_str()),
+ ("state dir", instance_paths.state_dir.as_str()),
+ ("logs dir", instance_paths.logs_dir.as_str()),
+ ("run dir", instance_paths.run_dir.as_str()),
+ ("secrets dir", instance_paths.secrets_dir.as_str()),
+ ("pid file", instance_paths.pid_file_path.as_str()),
+ ("stdout log", instance_paths.stdout_log_path.as_str()),
+ ("stderr log", instance_paths.stderr_log_path.as_str()),
+ ("metadata", instance_paths.metadata_path.as_str()),
+ ],
+ )?;
+ }
+ if let Some(record) = &view.instance_record {
+ let mut record_rows = vec![
+ ("management mode", record.management_mode.as_str()),
+ ("install state", record.install_state.as_str()),
+ ("binary path", record.binary_path.as_str()),
+ ("config path", record.config_path.as_str()),
+ ("logs path", record.logs_path.as_str()),
+ ("run path", record.run_path.as_str()),
+ ("installed version", record.installed_version.as_str()),
+ ];
+ if let Some(health_endpoint) = &record.health_endpoint {
+ record_rows.push(("health endpoint", health_endpoint.as_str()));
+ }
+ if let Some(secret_material_ref) = &record.secret_material_ref {
+ record_rows.push(("secret material ref", secret_material_ref.as_str()));
+ }
+ if let Some(last_started_at) = &record.last_started_at {
+ record_rows.push(("last started at", last_started_at.as_str()));
+ }
+ if let Some(last_stopped_at) = &record.last_stopped_at {
+ record_rows.push(("last stopped at", last_stopped_at.as_str()));
+ }
+ if let Some(notes) = &record.notes {
+ record_rows.push(("notes", notes.as_str()));
+ }
+ render_pairs(stdout, "instance record", record_rows.as_slice())?;
+ }
+ if !view.lifecycle_actions.is_empty() {
+ writeln!(stdout, "lifecycle actions: {}", view.lifecycle_actions.join(", "))?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ Ok(())
+}
+
fn format_capability_binding_target(
binding: &crate::domain::runtime::CapabilityBindingRuntimeView,
) -> String {
@@ -2321,6 +2535,18 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::RpcSessions(_) => "rpc sessions",
CommandView::RpcStatus(_) => "rpc status",
CommandView::RelayList(_) => "relay ls",
+ CommandView::RuntimeAction(view) => match view.action.as_str() {
+ "install" => "runtime install",
+ "uninstall" => "runtime uninstall",
+ "start" => "runtime start",
+ "stop" => "runtime stop",
+ "restart" => "runtime restart",
+ "config_set" => "runtime config set",
+ _ => "runtime",
+ },
+ CommandView::RuntimeConfigShow(_) => "runtime config show",
+ CommandView::RuntimeLogs(_) => "runtime logs",
+ CommandView::RuntimeStatus(_) => "runtime status",
CommandView::SignerStatus(_) => "signer status",
CommandView::SyncPull(_) => "sync pull",
CommandView::SyncPush(_) => "sync push",
diff --git a/src/runtime/management.rs b/src/runtime/management.rs
@@ -0,0 +1,743 @@
+use std::path::PathBuf;
+
+use radroots_runtime_manager::{
+ BootstrapRuntimeContract, ManagedRuntimeHealthState, ManagedRuntimeInstanceRecord,
+ ManagedRuntimeInstanceRegistry, ManagedRuntimeInstallState, ManagementModeContract,
+ RadrootsRuntimeManagementContract, parse_contract_str, resolve_instance_paths,
+ resolve_shared_paths,
+};
+use radroots_runtime_paths::{RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver};
+
+use crate::domain::runtime::{
+ RuntimeActionView, RuntimeInstancePathsView, RuntimeInstanceRecordView, RuntimeLogsView,
+ RuntimeManagedConfigView, RuntimeStatusView,
+};
+use crate::runtime::{RuntimeError, config::RuntimeConfig};
+
+const MANAGEMENT_CONTRACT_RAW: &str = include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../../../../foundation/contracts/runtime/management.toml"
+));
+const DEFERRED_LIFECYCLE_SLICE: &str = "rpv1-rpi.5";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RuntimeCommandAvailability {
+ Success,
+ Unconfigured,
+ Unsupported,
+}
+
+#[derive(Debug, Clone)]
+pub struct RuntimeInspection<T> {
+ pub availability: RuntimeCommandAvailability,
+ pub view: T,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum RuntimeLifecycleAction {
+ Install,
+ Uninstall,
+ Start,
+ Stop,
+ Restart,
+ ConfigSet,
+}
+
+impl RuntimeLifecycleAction {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Install => "install",
+ Self::Uninstall => "uninstall",
+ Self::Start => "start",
+ Self::Stop => "stop",
+ Self::Restart => "restart",
+ Self::ConfigSet => "config_set",
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RuntimeConfigMutationRequest {
+ pub runtime_id: String,
+ pub instance_id: Option<String>,
+ pub key: String,
+ pub value: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RuntimeGroup {
+ ActiveManagedTarget,
+ DefinedManagedTarget,
+ BootstrapOnly,
+ Unknown,
+}
+
+impl RuntimeGroup {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::ActiveManagedTarget => "active_managed_target",
+ Self::DefinedManagedTarget => "defined_managed_target",
+ Self::BootstrapOnly => "bootstrap_only",
+ Self::Unknown => "unknown",
+ }
+ }
+
+ fn posture(self) -> &'static str {
+ match self {
+ Self::ActiveManagedTarget => "active_managed_target",
+ Self::DefinedManagedTarget => "defined_future_target",
+ Self::BootstrapOnly => "bootstrap_only_direct_binding",
+ Self::Unknown => "unknown_runtime",
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct RuntimeManagementContext {
+ contract: RadrootsRuntimeManagementContract,
+ shared_paths: radroots_runtime_manager::ManagedRuntimeSharedPaths,
+ registry: ManagedRuntimeInstanceRegistry,
+}
+
+#[derive(Debug, Clone)]
+struct RuntimeTarget {
+ runtime_id: String,
+ instance_id: String,
+ instance_source: String,
+ runtime_group: RuntimeGroup,
+ management_mode: Option<String>,
+ mode_contract: Option<ManagementModeContract>,
+ bootstrap: Option<BootstrapRuntimeContract>,
+ instance_record: Option<ManagedRuntimeInstanceRecord>,
+ predicted_paths: Option<radroots_runtime_manager::ManagedRuntimeInstancePaths>,
+ registry_path: PathBuf,
+}
+
+pub fn inspect_status(
+ config: &RuntimeConfig,
+ runtime_id: &str,
+ instance_id: Option<&str>,
+) -> Result<RuntimeInspection<RuntimeStatusView>, RuntimeError> {
+ let context = load_management_context(config)?;
+ let target = resolve_runtime_target(&context, runtime_id, instance_id);
+ let availability = if target.runtime_group == RuntimeGroup::Unknown {
+ RuntimeCommandAvailability::Unconfigured
+ } else {
+ RuntimeCommandAvailability::Success
+ };
+ Ok(RuntimeInspection {
+ availability,
+ view: status_view(&target, &context.contract.lifecycle.actions),
+ })
+}
+
+pub fn inspect_logs(
+ config: &RuntimeConfig,
+ runtime_id: &str,
+ instance_id: Option<&str>,
+) -> Result<RuntimeInspection<RuntimeLogsView>, RuntimeError> {
+ let context = load_management_context(config)?;
+ let target = resolve_runtime_target(&context, runtime_id, instance_id);
+ let (availability, view) = logs_view(&target);
+ Ok(RuntimeInspection { availability, view })
+}
+
+pub fn inspect_config_show(
+ config: &RuntimeConfig,
+ runtime_id: &str,
+ instance_id: Option<&str>,
+) -> Result<RuntimeInspection<RuntimeManagedConfigView>, RuntimeError> {
+ let context = load_management_context(config)?;
+ let target = resolve_runtime_target(&context, runtime_id, instance_id);
+ let (availability, view) = config_show_view(&target);
+ Ok(RuntimeInspection { availability, view })
+}
+
+pub fn inspect_action(
+ config: &RuntimeConfig,
+ runtime_id: &str,
+ instance_id: Option<&str>,
+ action: RuntimeLifecycleAction,
+) -> Result<RuntimeInspection<RuntimeActionView>, RuntimeError> {
+ let context = load_management_context(config)?;
+ let target = resolve_runtime_target(&context, runtime_id, instance_id);
+ let (availability, view) = action_view(&target, action, None);
+ Ok(RuntimeInspection { availability, view })
+}
+
+pub fn inspect_config_set(
+ config: &RuntimeConfig,
+ request: &RuntimeConfigMutationRequest,
+) -> Result<RuntimeInspection<RuntimeActionView>, RuntimeError> {
+ let context = load_management_context(config)?;
+ let target = resolve_runtime_target(
+ &context,
+ request.runtime_id.as_str(),
+ request.instance_id.as_deref(),
+ );
+ let detail = Some(format!(
+ "requested managed config mutation {}={} for runtime `{}` instance `{}`; generic config mutation lands in {}",
+ request.key, request.value, target.runtime_id, target.instance_id, DEFERRED_LIFECYCLE_SLICE
+ ));
+ let (availability, view) = action_view(&target, RuntimeLifecycleAction::ConfigSet, detail);
+ Ok(RuntimeInspection { availability, view })
+}
+
+fn load_management_context(config: &RuntimeConfig) -> Result<RuntimeManagementContext, RuntimeError> {
+ let contract = parse_contract_str(MANAGEMENT_CONTRACT_RAW)?;
+ let profile = cli_path_profile(config)?;
+ let overrides = cli_path_overrides(config)?;
+ let resolver = RadrootsPathResolver::current();
+ let mode_id = active_management_mode_for_profile(&contract, profile)?;
+ let shared_paths = resolve_shared_paths(&contract, &resolver, profile, &overrides, mode_id)?;
+ let registry = radroots_runtime_manager::load_registry(&shared_paths.instance_registry_path)?;
+ Ok(RuntimeManagementContext {
+ contract,
+ shared_paths,
+ registry,
+ })
+}
+
+fn cli_path_profile(config: &RuntimeConfig) -> Result<RadrootsPathProfile, RuntimeError> {
+ match config.paths.profile.as_str() {
+ "interactive_user" => Ok(RadrootsPathProfile::InteractiveUser),
+ "repo_local" => Ok(RadrootsPathProfile::RepoLocal),
+ other => Err(RuntimeError::Config(format!(
+ "runtime management only supports cli path profiles `interactive_user` and `repo_local`, got `{other}`"
+ ))),
+ }
+}
+
+fn cli_path_overrides(config: &RuntimeConfig) -> Result<RadrootsPathOverrides, RuntimeError> {
+ match config.paths.profile.as_str() {
+ "interactive_user" => Ok(RadrootsPathOverrides::default()),
+ "repo_local" => {
+ let Some(repo_local_root) = &config.paths.repo_local_root else {
+ return Err(RuntimeError::Config(
+ "repo_local runtime management requires a repo-local root override".to_owned(),
+ ));
+ };
+ Ok(RadrootsPathOverrides::repo_local(repo_local_root))
+ }
+ other => Err(RuntimeError::Config(format!(
+ "runtime management only supports cli path profiles `interactive_user` and `repo_local`, got `{other}`"
+ ))),
+ }
+}
+
+fn active_management_mode_for_profile<'a>(
+ contract: &'a RadrootsRuntimeManagementContract,
+ profile: RadrootsPathProfile,
+) -> Result<&'a str, RuntimeError> {
+ let profile_id = profile.to_string();
+ contract
+ .mode
+ .iter()
+ .find(|(_, mode)| {
+ mode.contract_state == "active"
+ && mode.supported_profiles.iter().any(|entry| entry == &profile_id)
+ })
+ .map(|(mode_id, _)| mode_id.as_str())
+ .ok_or_else(|| {
+ RuntimeError::Config(format!(
+ "no active runtime-management mode supports cli profile `{profile_id}`"
+ ))
+ })
+}
+
+fn resolve_runtime_target(
+ context: &RuntimeManagementContext,
+ runtime_id: &str,
+ requested_instance_id: Option<&str>,
+) -> RuntimeTarget {
+ 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());
+ let mode_contract = management_mode
+ .as_ref()
+ .and_then(|mode_id| context.contract.mode.get(mode_id).cloned());
+ let instance_record = context
+ .registry
+ .instances
+ .iter()
+ .find(|record| record.runtime_id == runtime_id && record.instance_id == instance_id)
+ .cloned();
+ let predicted_paths = if runtime_group == RuntimeGroup::ActiveManagedTarget {
+ Some(resolve_instance_paths(
+ &context.shared_paths,
+ runtime_id,
+ instance_id.as_str(),
+ ))
+ } else {
+ None
+ };
+
+ RuntimeTarget {
+ runtime_id: runtime_id.to_owned(),
+ instance_id,
+ instance_source,
+ runtime_group,
+ management_mode,
+ mode_contract,
+ bootstrap,
+ instance_record,
+ predicted_paths,
+ registry_path: context.shared_paths.instance_registry_path.clone(),
+ }
+}
+
+fn runtime_group(contract: &RadrootsRuntimeManagementContract, runtime_id: &str) -> RuntimeGroup {
+ if contract
+ .managed_runtime_targets
+ .active
+ .iter()
+ .any(|entry| entry == runtime_id)
+ {
+ RuntimeGroup::ActiveManagedTarget
+ } else if contract
+ .managed_runtime_targets
+ .defined
+ .iter()
+ .any(|entry| entry == runtime_id)
+ {
+ RuntimeGroup::DefinedManagedTarget
+ } else if contract
+ .managed_runtime_targets
+ .bootstrap_only
+ .iter()
+ .any(|entry| entry == runtime_id)
+ {
+ RuntimeGroup::BootstrapOnly
+ } else {
+ RuntimeGroup::Unknown
+ }
+}
+
+fn status_view(target: &RuntimeTarget, lifecycle_actions: &[String]) -> RuntimeStatusView {
+ let install_state = target
+ .instance_record
+ .as_ref()
+ .map(|record| install_state_label(record.install_state))
+ .unwrap_or_else(|| install_state_label(ManagedRuntimeInstallState::NotInstalled));
+ let (health_state, health_source) = infer_health_state(target);
+
+ RuntimeStatusView {
+ runtime_id: target.runtime_id.clone(),
+ instance_id: target.instance_id.clone(),
+ instance_source: target.instance_source.clone(),
+ 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(),
+ detail: status_detail(target),
+ management_mode: target.management_mode.clone(),
+ service_manager_integration: target
+ .mode_contract
+ .as_ref()
+ .map(|mode| mode.service_manager_integration),
+ uses_absolute_binary_paths: target
+ .mode_contract
+ .as_ref()
+ .map(|mode| mode.uses_absolute_binary_paths),
+ preferred_cli_binding: target.bootstrap.as_ref().map(|entry| entry.preferred_cli_binding),
+ install_state: install_state.to_owned(),
+ health_state: health_state.to_owned(),
+ health_source: health_source.to_owned(),
+ registry_path: target.registry_path.display().to_string(),
+ lifecycle_actions: if target.runtime_group == RuntimeGroup::ActiveManagedTarget {
+ lifecycle_actions.to_vec()
+ } else {
+ Vec::new()
+ },
+ instance_paths: target.predicted_paths.as_ref().map(instance_paths_view),
+ instance_record: target.instance_record.as_ref().map(instance_record_view),
+ }
+}
+
+fn logs_view(target: &RuntimeTarget) -> (RuntimeCommandAvailability, RuntimeLogsView) {
+ let stdout_log_path = target
+ .predicted_paths
+ .as_ref()
+ .map(|paths| paths.stdout_log_path.display().to_string());
+ let stderr_log_path = target
+ .predicted_paths
+ .as_ref()
+ .map(|paths| paths.stderr_log_path.display().to_string());
+ let availability = match target.runtime_group {
+ RuntimeGroup::Unknown => RuntimeCommandAvailability::Unconfigured,
+ RuntimeGroup::ActiveManagedTarget => RuntimeCommandAvailability::Success,
+ RuntimeGroup::DefinedManagedTarget | RuntimeGroup::BootstrapOnly => {
+ if target.instance_record.is_some() {
+ RuntimeCommandAvailability::Success
+ } else {
+ RuntimeCommandAvailability::Unsupported
+ }
+ }
+ };
+ let detail = match target.runtime_group {
+ RuntimeGroup::ActiveManagedTarget => {
+ "runtime logs report the managed stdout/stderr locations; lifecycle execution lands in rpv1-rpi.5"
+ .to_owned()
+ }
+ RuntimeGroup::DefinedManagedTarget => format!(
+ "runtime `{}` is only a defined future managed target; no active generic logs surface exists without a registered instance",
+ target.runtime_id
+ ),
+ RuntimeGroup::BootstrapOnly => format!(
+ "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed logs are not admitted",
+ target.runtime_id
+ ),
+ RuntimeGroup::Unknown => unknown_runtime_detail(target),
+ };
+
+ (
+ availability,
+ RuntimeLogsView {
+ runtime_id: target.runtime_id.clone(),
+ instance_id: target.instance_id.clone(),
+ instance_source: target.instance_source.clone(),
+ runtime_group: target.runtime_group.as_str().to_owned(),
+ state: match availability {
+ RuntimeCommandAvailability::Success => "ready".to_owned(),
+ RuntimeCommandAvailability::Unconfigured => "unknown_runtime".to_owned(),
+ RuntimeCommandAvailability::Unsupported => "unsupported".to_owned(),
+ },
+ source: "runtime management contract + shared instance registry".to_owned(),
+ detail,
+ stdout_log_path: stdout_log_path.clone().or_else(|| {
+ target
+ .instance_record
+ .as_ref()
+ .map(|record| record.logs_path.join("stdout.log").display().to_string())
+ }),
+ stderr_log_path: stderr_log_path.clone().or_else(|| {
+ target
+ .instance_record
+ .as_ref()
+ .map(|record| record.logs_path.join("stderr.log").display().to_string())
+ }),
+ stdout_log_present: path_present(stdout_log_path.as_deref()).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_deref()).unwrap_or_else(|| {
+ target
+ .instance_record
+ .as_ref()
+ .is_some_and(|record| record.logs_path.join("stderr.log").exists())
+ }),
+ },
+ )
+}
+
+fn config_show_view(
+ target: &RuntimeTarget,
+) -> (RuntimeCommandAvailability, RuntimeManagedConfigView) {
+ let availability = match target.runtime_group {
+ RuntimeGroup::Unknown => RuntimeCommandAvailability::Unconfigured,
+ RuntimeGroup::ActiveManagedTarget => RuntimeCommandAvailability::Success,
+ RuntimeGroup::DefinedManagedTarget | RuntimeGroup::BootstrapOnly => {
+ if target.instance_record.is_some() {
+ RuntimeCommandAvailability::Success
+ } else {
+ RuntimeCommandAvailability::Unsupported
+ }
+ }
+ };
+ let config_path = target
+ .instance_record
+ .as_ref()
+ .map(|record| record.config_path.display().to_string());
+ let detail = match target.runtime_group {
+ RuntimeGroup::ActiveManagedTarget => {
+ if config_path.is_some() {
+ "runtime config show reports the managed config location without mutating bindings or lifecycle state"
+ .to_owned()
+ } else {
+ format!(
+ "managed runtime `{}` has no registered instance config yet; config bootstrap lands in {}",
+ target.runtime_id, DEFERRED_LIFECYCLE_SLICE
+ )
+ }
+ }
+ RuntimeGroup::DefinedManagedTarget => format!(
+ "runtime `{}` is only a defined future managed target; generic config surfaces are not admitted without a registered instance",
+ target.runtime_id
+ ),
+ RuntimeGroup::BootstrapOnly => format!(
+ "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed config is not admitted",
+ target.runtime_id
+ ),
+ RuntimeGroup::Unknown => unknown_runtime_detail(target),
+ };
+
+ (
+ availability,
+ RuntimeManagedConfigView {
+ runtime_id: target.runtime_id.clone(),
+ instance_id: target.instance_id.clone(),
+ instance_source: target.instance_source.clone(),
+ runtime_group: target.runtime_group.as_str().to_owned(),
+ state: match availability {
+ RuntimeCommandAvailability::Success => {
+ if config_path.is_some() {
+ "ready".to_owned()
+ } else {
+ "not_installed".to_owned()
+ }
+ }
+ RuntimeCommandAvailability::Unconfigured => "unknown_runtime".to_owned(),
+ RuntimeCommandAvailability::Unsupported => "unsupported".to_owned(),
+ },
+ source: "runtime management contract + shared instance registry".to_owned(),
+ detail,
+ config_format: target.bootstrap.as_ref().map(|entry| entry.config_format.clone()),
+ config_path: config_path.clone(),
+ config_present: config_path
+ .as_deref()
+ .is_some_and(|path| PathBuf::from(path).exists()),
+ requires_bootstrap_secret: target
+ .bootstrap
+ .as_ref()
+ .map(|entry| entry.requires_bootstrap_secret),
+ requires_config_bootstrap: target
+ .bootstrap
+ .as_ref()
+ .map(|entry| entry.requires_config_bootstrap),
+ requires_signer_provider: target
+ .bootstrap
+ .as_ref()
+ .map(|entry| entry.requires_signer_provider),
+ },
+ )
+}
+
+fn action_view(
+ target: &RuntimeTarget,
+ action: RuntimeLifecycleAction,
+ detail_override: Option<String>,
+) -> (RuntimeCommandAvailability, RuntimeActionView) {
+ let (availability, state, detail, next_step) = match target.runtime_group {
+ RuntimeGroup::ActiveManagedTarget => (
+ RuntimeCommandAvailability::Unsupported,
+ "deferred",
+ detail_override.unwrap_or_else(|| {
+ format!(
+ "runtime {} `{}` is reserved for {} so lifecycle execution can land after the generic command family is stable",
+ action.as_str().replace('_', " "),
+ target.runtime_id,
+ DEFERRED_LIFECYCLE_SLICE
+ )
+ }),
+ Some(DEFERRED_LIFECYCLE_SLICE.to_owned()),
+ ),
+ RuntimeGroup::DefinedManagedTarget => (
+ RuntimeCommandAvailability::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,
+ ),
+ RuntimeGroup::BootstrapOnly => (
+ RuntimeCommandAvailability::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,
+ ),
+ RuntimeGroup::Unknown => (
+ RuntimeCommandAvailability::Unconfigured,
+ "unknown_runtime",
+ detail_override.unwrap_or_else(|| unknown_runtime_detail(target)),
+ None,
+ ),
+ };
+
+ (
+ availability,
+ RuntimeActionView {
+ 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_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,
+ },
+ )
+}
+
+fn status_state(target: &RuntimeTarget) -> &'static str {
+ match target.runtime_group {
+ RuntimeGroup::ActiveManagedTarget => match target.instance_record.as_ref() {
+ Some(record) => install_state_label(record.install_state),
+ None => "not_installed",
+ },
+ RuntimeGroup::DefinedManagedTarget => "defined_not_active",
+ RuntimeGroup::BootstrapOnly => "bootstrap_only",
+ RuntimeGroup::Unknown => "unknown_runtime",
+ }
+}
+
+fn status_detail(target: &RuntimeTarget) -> String {
+ match target.runtime_group {
+ RuntimeGroup::ActiveManagedTarget => match &target.instance_record {
+ Some(record) => format!(
+ "managed runtime `{}` instance `{}` is registered with config at {}; generic lifecycle execution lands in {}",
+ target.runtime_id,
+ target.instance_id,
+ record.config_path.display(),
+ DEFERRED_LIFECYCLE_SLICE
+ ),
+ None => format!(
+ "managed runtime `{}` has no registered instance `{}` in {}; lifecycle bootstrap lands in {}",
+ target.runtime_id,
+ target.instance_id,
+ target.registry_path.display(),
+ DEFERRED_LIFECYCLE_SLICE
+ ),
+ },
+ RuntimeGroup::DefinedManagedTarget => format!(
+ "runtime `{}` is defined in the management contract but not yet admitted as an active managed target",
+ target.runtime_id
+ ),
+ RuntimeGroup::BootstrapOnly => format!(
+ "runtime `{}` is bootstrap_only in the management contract and remains direct-bindable outside managed lifecycle in this wave",
+ target.runtime_id
+ ),
+ RuntimeGroup::Unknown => unknown_runtime_detail(target),
+ }
+}
+
+fn unknown_runtime_detail(target: &RuntimeTarget) -> String {
+ format!(
+ "runtime `{}` is not present in the current runtime-management contract",
+ target.runtime_id
+ )
+}
+
+fn infer_health_state(target: &RuntimeTarget) -> (&'static str, &'static str) {
+ let Some(record) = &target.instance_record else {
+ return (
+ health_state_label(ManagedRuntimeHealthState::NotInstalled),
+ "registry_absent",
+ );
+ };
+ if record.install_state == ManagedRuntimeInstallState::Failed {
+ return (
+ health_state_label(ManagedRuntimeHealthState::Failed),
+ "registry_install_state",
+ );
+ }
+
+ let pid_path = target
+ .predicted_paths
+ .as_ref()
+ .map(|paths| paths.pid_file_path.clone())
+ .unwrap_or_else(|| record.run_path.join("runtime.pid"));
+
+ if pid_path.exists() {
+ return (
+ health_state_label(ManagedRuntimeHealthState::Running),
+ "pid_file_presence",
+ );
+ }
+
+ match record.install_state {
+ ManagedRuntimeInstallState::NotInstalled => (
+ health_state_label(ManagedRuntimeHealthState::NotInstalled),
+ "registry_install_state",
+ ),
+ ManagedRuntimeInstallState::Installed | ManagedRuntimeInstallState::Configured => (
+ health_state_label(ManagedRuntimeHealthState::Stopped),
+ "pid_file_absent",
+ ),
+ ManagedRuntimeInstallState::Failed => (
+ health_state_label(ManagedRuntimeHealthState::Failed),
+ "registry_install_state",
+ ),
+ }
+}
+
+fn install_state_label(state: ManagedRuntimeInstallState) -> &'static str {
+ match state {
+ ManagedRuntimeInstallState::NotInstalled => "not_installed",
+ ManagedRuntimeInstallState::Installed => "installed",
+ ManagedRuntimeInstallState::Configured => "configured",
+ ManagedRuntimeInstallState::Failed => "failed",
+ }
+}
+
+fn health_state_label(state: ManagedRuntimeHealthState) -> &'static str {
+ match state {
+ ManagedRuntimeHealthState::NotInstalled => "not_installed",
+ ManagedRuntimeHealthState::Stopped => "stopped",
+ ManagedRuntimeHealthState::Starting => "starting",
+ ManagedRuntimeHealthState::Running => "running",
+ ManagedRuntimeHealthState::Degraded => "degraded",
+ ManagedRuntimeHealthState::Failed => "failed",
+ }
+}
+
+fn instance_paths_view(
+ paths: &radroots_runtime_manager::ManagedRuntimeInstancePaths,
+) -> RuntimeInstancePathsView {
+ RuntimeInstancePathsView {
+ install_dir: paths.install_dir.display().to_string(),
+ state_dir: paths.state_dir.display().to_string(),
+ logs_dir: paths.logs_dir.display().to_string(),
+ run_dir: paths.run_dir.display().to_string(),
+ secrets_dir: paths.secrets_dir.display().to_string(),
+ pid_file_path: paths.pid_file_path.display().to_string(),
+ stdout_log_path: paths.stdout_log_path.display().to_string(),
+ stderr_log_path: paths.stderr_log_path.display().to_string(),
+ metadata_path: paths.metadata_path.display().to_string(),
+ }
+}
+
+fn instance_record_view(record: &ManagedRuntimeInstanceRecord) -> RuntimeInstanceRecordView {
+ RuntimeInstanceRecordView {
+ management_mode: record.management_mode.clone(),
+ install_state: install_state_label(record.install_state).to_owned(),
+ binary_path: record.binary_path.display().to_string(),
+ config_path: record.config_path.display().to_string(),
+ logs_path: record.logs_path.display().to_string(),
+ run_path: record.run_path.display().to_string(),
+ installed_version: record.installed_version.clone(),
+ health_endpoint: record.health_endpoint.clone(),
+ secret_material_ref: record.secret_material_ref.clone(),
+ last_started_at: record.last_started_at.clone(),
+ last_stopped_at: record.last_stopped_at.clone(),
+ notes: record.notes.clone(),
+ }
+}
+
+fn path_present(path: Option<&str>) -> Option<bool> {
+ path.map(|value| PathBuf::from(value).exists())
+}
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -7,6 +7,7 @@ pub mod job;
pub mod listing;
pub mod local;
pub mod logging;
+pub mod management;
pub mod myc;
pub mod network;
pub mod order;
@@ -33,6 +34,8 @@ pub enum RuntimeError {
Json(#[from] serde_json::Error),
#[error("failed to write output: {0}")]
Io(#[from] std::io::Error),
+ #[error("runtime manager error: {0}")]
+ RuntimeManager(#[from] radroots_runtime_manager::RadrootsRuntimeManagerError),
}
impl RuntimeError {
@@ -44,7 +47,8 @@ impl RuntimeError {
| Self::Sql(_)
| Self::ReplicaSync(_)
| Self::Json(_)
- | Self::Io(_) => ExitCode::from(1),
+ | Self::Io(_)
+ | Self::RuntimeManager(_) => ExitCode::from(1),
}
}
}
diff --git a/tests/runtime_management.rs b/tests/runtime_management.rs
@@ -0,0 +1,219 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use serde_json::Value;
+use tempfile::tempdir;
+
+fn appdata_root(workdir: &Path) -> std::path::PathBuf {
+ workdir.join("roaming").join("Radroots")
+}
+
+fn localappdata_root(workdir: &Path) -> std::path::PathBuf {
+ workdir.join("local").join("Radroots")
+}
+
+fn interactive_root(workdir: &Path) -> std::path::PathBuf {
+ if cfg!(windows) {
+ localappdata_root(workdir)
+ } else {
+ workdir.join("home").join(".radroots")
+ }
+}
+
+fn config_root(workdir: &Path) -> std::path::PathBuf {
+ if cfg!(windows) {
+ appdata_root(workdir).join("config")
+ } else {
+ interactive_root(workdir).join("config")
+ }
+}
+
+fn runtime_manager_registry_path(workdir: &Path) -> std::path::PathBuf {
+ config_root(workdir).join("shared/runtime-manager/instances.toml")
+}
+
+fn runtime_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ command.env("APPDATA", workdir.join("roaming"));
+ command.env("LOCALAPPDATA", workdir.join("local"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_CLI_PATHS_PROFILE",
+ "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_ACCOUNT_SECRET_BACKEND",
+ "RADROOTS_ACCOUNT_SECRET_FALLBACK",
+ "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
+ ] {
+ command.env_remove(key);
+ }
+ command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false");
+ command
+}
+
+#[test]
+fn runtime_status_reports_active_managed_target_truth() {
+ let dir = tempdir().expect("tempdir");
+ let output = runtime_command_in(dir.path())
+ .args(["--json", "runtime", "status", "radrootsd"])
+ .output()
+ .expect("runtime status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["runtime_id"], "radrootsd");
+ assert_eq!(json["runtime_group"], "active_managed_target");
+ assert_eq!(json["management_posture"], "active_managed_target");
+ assert_eq!(json["state"], "not_installed");
+ assert_eq!(json["install_state"], "not_installed");
+ assert_eq!(json["health_state"], "not_installed");
+ assert_eq!(json["instance_id"], "local");
+ assert_eq!(json["instance_source"], "bootstrap_default");
+ assert_eq!(json["management_mode"], "interactive_user_managed");
+}
+
+#[test]
+fn runtime_status_reports_defined_future_target_truth() {
+ let dir = tempdir().expect("tempdir");
+ let output = runtime_command_in(dir.path())
+ .args(["--json", "runtime", "status", "myc"])
+ .output()
+ .expect("runtime status");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["runtime_id"], "myc");
+ assert_eq!(json["runtime_group"], "defined_managed_target");
+ assert_eq!(json["management_posture"], "defined_future_target");
+ assert_eq!(json["state"], "defined_not_active");
+ assert_eq!(json["lifecycle_actions"], Value::Array(vec![]));
+}
+
+#[test]
+fn runtime_install_is_exposed_but_truthfully_deferred() {
+ let dir = tempdir().expect("tempdir");
+ let output = runtime_command_in(dir.path())
+ .args(["--json", "runtime", "install", "radrootsd"])
+ .output()
+ .expect("runtime install");
+
+ assert_eq!(output.status.code(), Some(5));
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["action"], "install");
+ assert_eq!(json["runtime_id"], "radrootsd");
+ assert_eq!(json["state"], "deferred");
+ assert_eq!(json["mutates_bindings"], false);
+ assert_eq!(json["next_step"], "rpv1-rpi.5");
+}
+
+#[test]
+fn runtime_logs_reports_managed_log_locations() {
+ let dir = tempdir().expect("tempdir");
+ let output = runtime_command_in(dir.path())
+ .args(["--json", "runtime", "logs", "radrootsd"])
+ .output()
+ .expect("runtime logs");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["runtime_id"], "radrootsd");
+ assert_eq!(json["state"], "ready");
+ assert!(json["stdout_log_path"]
+ .as_str()
+ .expect("stdout log path")
+ .ends_with("shared/runtime-manager/radrootsd/local/stdout.log"));
+ assert!(json["stderr_log_path"]
+ .as_str()
+ .expect("stderr log path")
+ .ends_with("shared/runtime-manager/radrootsd/local/stderr.log"));
+}
+
+#[test]
+fn runtime_config_show_uses_registered_instance_config_path() {
+ let dir = tempdir().expect("tempdir");
+ let registry_path = runtime_manager_registry_path(dir.path());
+ let config_path = dir.path().join("managed").join("radrootsd-local.toml");
+ fs::create_dir_all(config_path.parent().expect("config parent")).expect("create config dir");
+ fs::write(
+ &config_path,
+ "[config.rpc]\naddr = \"127.0.0.1:7070\"\n[config.bridge]\nenabled = true\nbearer_token = \"redacted\"\n",
+ )
+ .expect("write config");
+ fs::create_dir_all(registry_path.parent().expect("registry parent")).expect("registry dir");
+ fs::write(
+ ®istry_path,
+ format!(
+ r#"schema = "radroots_runtime-instance-registry"
+schema_version = 1
+
+[[instances]]
+runtime_id = "radrootsd"
+instance_id = "local"
+management_mode = "interactive_user_managed"
+install_state = "configured"
+binary_path = "{binary_path}"
+config_path = "{config_path}"
+logs_path = "{logs_path}"
+run_path = "{run_path}"
+installed_version = "0.1.0"
+"#,
+ binary_path = dir.path().join("bin/radrootsd").display(),
+ config_path = config_path.display(),
+ logs_path = dir.path().join("managed/logs/radrootsd-local").display(),
+ run_path = dir.path().join("managed/run/radrootsd-local").display(),
+ ),
+ )
+ .expect("write registry");
+
+ let output = runtime_command_in(dir.path())
+ .args(["--json", "runtime", "config", "show", "radrootsd"])
+ .output()
+ .expect("runtime config show");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["runtime_id"], "radrootsd");
+ assert_eq!(json["config_format"], "toml");
+ assert_eq!(json["config_path"], config_path.display().to_string());
+ assert_eq!(json["config_present"], true);
+ assert_eq!(json["requires_bootstrap_secret"], true);
+ assert_eq!(json["requires_config_bootstrap"], true);
+}
+
+#[test]
+fn runtime_logs_rejects_bootstrap_only_runtime() {
+ let dir = tempdir().expect("tempdir");
+ let output = runtime_command_in(dir.path())
+ .args(["--json", "runtime", "logs", "hyf"])
+ .output()
+ .expect("runtime logs");
+
+ assert_eq!(output.status.code(), Some(5));
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["runtime_id"], "hyf");
+ assert_eq!(json["runtime_group"], "bootstrap_only");
+ assert_eq!(json["state"], "unsupported");
+}